From 95b55a6980560735c3497cbaee5ffe0807886e8e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Sat, 22 Feb 2025 10:55:36 +0100 Subject: [PATCH 1/4] UIComponent: Rewrite floating API The old API is difficult to work with because it will continue to render floating components even if they've been removed from the component tree. Additionally it can CME if components are removed from the floating list during rendering, further complicating the workarounds required. This new API fixes the issue by tracking when components are removed/added from/to the tree and updating its internal floating list accordingly. It also allows setting the floating state at any time, even before the component has a parent, another thing the old API did not support. The order in which floating components appear also differs in the new API. While the old API showed floating components in the order in which they were set to be floating, this often isn't all too useful when the order in which components are added/removed to/from the tree is not particularily well defined. As such, the new API choses to instead order floating components in exactly the same way as they appear in the component tree (pre-order tree traversal, i.e. first parent, then children). This results in consistent ordering and is generally the order you want for nested floating components to behave in a useful way. This has been implemented as a new, completely separate API instead of an ElementaVersion primarily to easy migration (the new API can be used even with Windows still on older ElementaVersions; both APIs can be used at the same time) but also because there isn't anything reasonable the old-API methods in `Window` could do in the new version, they really should have been internal to begin with. --- api/Elementa.api | 2 + .../gg/essential/elementa/UIComponent.kt | 70 ++++++++++++++-- .../essential/elementa/components/Window.kt | 28 +++++-- .../elementa/layoutdsl/containers.kt | 83 +------------------ 4 files changed, 90 insertions(+), 93 deletions(-) diff --git a/api/Elementa.api b/api/Elementa.api index 6b82ab59..24f284a6 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -100,6 +100,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable, public fun insertChildAt (Lgg/essential/elementa/UIComponent;I)Lgg/essential/elementa/UIComponent; public fun insertChildBefore (Lgg/essential/elementa/UIComponent;Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; public fun isChildOf (Lgg/essential/elementa/UIComponent;)Z + public final fun isFloating ()Z public fun isHovered ()Z protected final fun isInitialized ()Z public fun isPointInside (FF)Z @@ -144,6 +145,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable, public final fun setFontProvider (Lgg/essential/elementa/font/FontProvider;)Lgg/essential/elementa/UIComponent; public final fun setHeight (Lgg/essential/elementa/constraints/HeightConstraint;)Lgg/essential/elementa/UIComponent; protected final fun setInitialized (Z)V + public final fun setIsFloating (Z)V public final fun setLastDraggedMouseX (Ljava/lang/Double;)V public final fun setLastDraggedMouseY (Ljava/lang/Double;)V public final fun setMouseScrollListeners (Ljava/util/List;)V diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index 9331f819..e0b92e7e 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -53,8 +53,11 @@ abstract class UIComponent : Observable(), ReferenceHolder { private var childrenLocked = 0 init { - children.addObserver { _, _ -> requireChildrenUnlocked() } - children.addObserver { _, event -> setWindowCacheOnChangedChild(event) } + children.addObserver { _, event -> + requireChildrenUnlocked() + setWindowCacheOnChangedChild(event) + updateFloatingComponentsOnChangedChild(event) + } } open lateinit var parent: UIComponent @@ -110,7 +113,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { private var heldReferences = mutableListOf() protected var isInitialized = false - private var isFloating = false + private var isLegacyFloating = false private var didCallBeforeDraw = false private var warnedAboutBeforeDraw = false @@ -479,7 +482,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { val parentWindow = Window.of(this) this.forEachChild { child -> - if (child.isFloating) return@forEachChild + if (child.isLegacyFloating || child.isFloating) return@forEachChild // If the child is outside the current viewport, don't waste time drawing if (!this.alwaysDrawChildren() && !parentWindow.isAreaVisible( @@ -980,8 +983,65 @@ abstract class UIComponent : Observable(), ReferenceHolder { * Floating API */ + @set:JvmName("setIsFloating") // `setFloating` is taken by the old API + var isFloating: Boolean = false + set(value) { + if (value == field) return + field = value + recomputeFloatingComponents() + } + + internal var floatingComponents: List? = null // only allocated if used + + private fun recomputeFloatingComponents() { + val result = mutableListOf() + if (isFloating) { + result.add(this) + } + for (child in children) { + child.floatingComponents?.let { result.addAll(it) } + } + if ((floatingComponents ?: emptyList()) == result) { + return // unchanged + } + floatingComponents = result.takeUnless { it.isEmpty() } + + if (this is Window) { + if (hoveredFloatingComponent !in result) { + hoveredFloatingComponent = null + } + } else if (hasParent) { + parent.recomputeFloatingComponents() + } + } + + private fun updateFloatingComponentsOnChangedChild(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> { + val (_, child) = event.element + if (child.floatingComponents != null) { + recomputeFloatingComponents() + } + } + is ObservableRemoveEvent -> { + val (_, child) = event.element + if (child.floatingComponents != null) { + recomputeFloatingComponents() + } + } + is ObservableClearEvent -> { + if (floatingComponents != null) { + recomputeFloatingComponents() + } + } + } + } + + @Deprecated("The legacy floating API does not behave well when a component is removed from the tree.", ReplaceWith("isFloating = floating")) + @Suppress("DEPRECATION") fun setFloating(floating: Boolean) { - isFloating = floating + isLegacyFloating = floating if (floating) { Window.of(this).addFloatingComponent(this) diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index a71b8a9b..d7293a51 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -25,7 +25,7 @@ class Window @JvmOverloads constructor( private var systemTime = -1L private var currentMouseButton = -1 - private var floatingComponents = mutableListOf() + private var legacyFloatingComponents = mutableListOf() var hoveredFloatingComponent: UIComponent? = null var focusedComponent: UIComponent? = null @@ -98,7 +98,7 @@ class Window @JvmOverloads constructor( hoveredFloatingComponent = null val (mouseX, mouseY) = getMousePosition() - for (component in floatingComponents.reversed()) { + for (component in allFloatingComponentsInReverseOrder()) { if (component.isPointInside(mouseX, mouseY)) { hoveredFloatingComponent = component break @@ -159,7 +159,7 @@ class Window @JvmOverloads constructor( fun drawFloatingComponents(matrixStack: UMatrixStack) { requireMainThread() - val it = floatingComponents.iterator() + val it = legacyFloatingComponents.iterator() while (it.hasNext()) { val component = it.next() if (ofOrNull(component) == null) { @@ -168,6 +168,9 @@ class Window @JvmOverloads constructor( } component.drawCompat(matrixStack) } + for (component in floatingComponents ?: emptyList()) { + component.drawCompat(matrixStack) + } } override fun mouseScroll(delta: Double) { @@ -178,7 +181,7 @@ class Window @JvmOverloads constructor( requireMainThread() val (mouseX, mouseY) = getMousePosition() - for (floatingComponent in floatingComponents.reversed()) { + for (floatingComponent in allFloatingComponentsInReverseOrder()) { if (floatingComponent.isPointInside(mouseX, mouseY)) { floatingComponent.mouseScroll(delta) return @@ -211,7 +214,7 @@ class Window @JvmOverloads constructor( } } - for (floatingComponent in floatingComponents.reversed()) { + for (floatingComponent in allFloatingComponentsInReverseOrder()) { if (floatingComponent.isPointInside(mouseX.toFloat(), mouseY.toFloat())) { floatingComponent.mouseClick(mouseX, mouseY, button) dealWithFocusRequests() @@ -335,29 +338,36 @@ class Window @JvmOverloads constructor( * Floating API */ + private fun allFloatingComponentsInReverseOrder(): Sequence = + (floatingComponents ?: emptyList()).asReversed().asSequence() + + // Note: needs to be copied to guard against CME and for backwards compatibility + legacyFloatingComponents.reversed() + + @Deprecated("Internal API.", replaceWith = ReplaceWith("component.setFloating(true)")) fun addFloatingComponent(component: UIComponent) { if (isInitialized) { requireMainThread() } - if (floatingComponents.contains(component)) return + if (legacyFloatingComponents.contains(component)) return - floatingComponents.add(component) + legacyFloatingComponents.add(component) } + @Deprecated("Internal API.", replaceWith = ReplaceWith("component.setFloating(false)")) fun removeFloatingComponent(component: UIComponent) { if (isInitialized) { requireMainThread() } - floatingComponents.remove(component) + legacyFloatingComponents.remove(component) } /** * Overridden to including floating components. */ override fun hitTest(x: Float, y: Float): UIComponent { - for (component in floatingComponents.reversed()) { + for (component in allFloatingComponentsInReverseOrder()) { if (component.isPointInside(x, y)) { return component.hitTest(x, y) } 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 5662ccb4..faadf0a6 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 @@ -4,8 +4,6 @@ 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 @@ -18,8 +16,6 @@ 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 @@ -168,80 +164,9 @@ fun LayoutScope.floatingBox( 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()) + val box = box(modifier, block) + effect(box) { + box.isFloating = floating() } - container.addChildModifier(Modifier.alignBoth(Alignment.Center)) - return container(modifier = modifier, block = block) + return box } From c80bd8f18f347d8d725a900d800ccb33a3739473 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 24 Feb 2025 10:05:37 +0100 Subject: [PATCH 2/4] Inspector: Use new floating API --- api/Elementa.api | 1 - .../components/inspector/Inspector.kt | 23 ++----------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/api/Elementa.api b/api/Elementa.api index 24f284a6..bf091ad1 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -1152,7 +1152,6 @@ public final class gg/essential/elementa/components/inspector/Inspector : gg/ess public fun (Lgg/essential/elementa/UIComponent;Ljava/awt/Color;Ljava/awt/Color;F)V public fun (Lgg/essential/elementa/UIComponent;Ljava/awt/Color;Ljava/awt/Color;FLgg/essential/elementa/constraints/HeightConstraint;)V public synthetic fun (Lgg/essential/elementa/UIComponent;Ljava/awt/Color;Ljava/awt/Color;FLgg/essential/elementa/constraints/HeightConstraint;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun animationFrame ()V public fun draw (Lgg/essential/universal/UMatrixStack;)V } diff --git a/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt b/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt index d483804e..803e6625 100644 --- a/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt +++ b/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt @@ -45,6 +45,8 @@ class Inspector @JvmOverloads constructor( height = ChildBasedSizeConstraint() } + isFloating = true + container = UIBlock(backgroundColor).constrain { width = ChildBasedMaxSizeConstraint() height = ChildBasedSizeConstraint() @@ -230,28 +232,7 @@ class Inspector @JvmOverloads constructor( } } - private fun UIComponent.isMounted(): Boolean = - parent == this || (this in parent.children && parent.isMounted()) - - override fun animationFrame() { - super.animationFrame() - - // Make sure we are the top-most component (last to draw and first to receive input) - Window.enqueueRenderOperation { - setFloating(false) - if (isMounted()) { // only if we are still mounted - setFloating(true) - } - } - } - override fun draw(matrixStack: UMatrixStack) { - // If we got removed from our parent, we need to un-float ourselves - if (!isMounted()) { - Window.enqueueRenderOperation { setFloating(false) } - return - } - separator1.setWidth(container.getWidth().pixels()) separator2.setWidth(container.getWidth().pixels()) From 9d920dcca7150f2ef951793e62c63baf01f2fb86 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 24 Feb 2025 10:04:44 +0100 Subject: [PATCH 3/4] UIComponent: Introduce new UpdateFunc API Intended to be used in almost all places where `animationFrame` was used before (only major exception being animated constraints, which will receive a different replacement). Unlike `animationFrame`, which runs at a fixed rate of (by default) 244 times per second, the [UpdateFunc] API is variable rate, meaning it'll be called exactly once per frame with the time that passed since the last frame. This allows it to match the true framerate exactly, animations won't slow down under high load, and it won't waste tons of CPU time on traversing the entire tree potentially mutliple times each frame. The UpdateFunc API notably also allows modifying the component hierarchy and accessing layout information directly from within a UpdateFunc call, both of which are quite tricky to do correctly from `animationFrame`. --- api/Elementa.api | 4 + .../gg/essential/elementa/UIComponent.kt | 264 +++++++++++++++++- .../essential/elementa/components/Window.kt | 21 ++ .../elementa/components/updateFunc.kt | 19 ++ .../gg/essential/elementa/effects/Effect.kt | 21 ++ 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/gg/essential/elementa/components/updateFunc.kt diff --git a/api/Elementa.api b/api/Elementa.api index bf091ad1..e77bd466 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -24,6 +24,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable, public fun ()V public fun addChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; public fun addChildren ([Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; + public final fun addUpdateFunc (Lkotlin/jvm/functions/Function2;)V public fun afterDraw ()V public fun afterDraw (Lgg/essential/universal/UMatrixStack;)V public final fun afterDrawCompat (Lgg/essential/universal/UMatrixStack;)V @@ -134,6 +135,7 @@ public abstract class gg/essential/elementa/UIComponent : java/util/Observable, public fun removeChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; public final fun removeEffect (Lgg/essential/elementa/effects/Effect;)V public final fun removeEffect (Ljava/lang/Class;)V + public final fun removeUpdateFunc (Lkotlin/jvm/functions/Function2;)V public fun replaceChild (Lgg/essential/elementa/UIComponent;Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; protected final fun requireChildrenUnlocked ()V public final fun setChildOf (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; @@ -2583,6 +2585,7 @@ public final class gg/essential/elementa/dsl/UtilitiesKt { public abstract class gg/essential/elementa/effects/Effect { protected field boundComponent Lgg/essential/elementa/UIComponent; public fun ()V + protected final fun addUpdateFunc (Lkotlin/jvm/functions/Function2;)V public fun afterDraw ()V public fun afterDraw (Lgg/essential/universal/UMatrixStack;)V public final fun afterDrawCompat (Lgg/essential/universal/UMatrixStack;)V @@ -2595,6 +2598,7 @@ public abstract class gg/essential/elementa/effects/Effect { public final fun beforeDrawCompat (Lgg/essential/universal/UMatrixStack;)V public final fun bindComponent (Lgg/essential/elementa/UIComponent;)V protected final fun getBoundComponent ()Lgg/essential/elementa/UIComponent; + protected final fun removeUpdateFunc (Lkotlin/jvm/functions/Function2;)V protected final fun setBoundComponent (Lgg/essential/elementa/UIComponent;)V public fun setup ()V } diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index e0b92e7e..292a6d57 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -1,7 +1,10 @@ package gg.essential.elementa +import gg.essential.elementa.components.NOP_UPDATE_FUNC +import gg.essential.elementa.components.NopUpdateFuncList import gg.essential.elementa.components.UIBlock import gg.essential.elementa.components.UIContainer +import gg.essential.elementa.components.UpdateFunc import gg.essential.elementa.components.Window import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.* @@ -49,7 +52,11 @@ abstract class UIComponent : Observable(), ReferenceHolder { return field } open val children = CopyOnWriteArrayList().observable() - val effects = mutableListOf() + val effects: MutableList = mutableListOf().observable().apply { + addObserver { _, event -> + updateUpdateFuncsOnChangedEffect(event) + } + } private var childrenLocked = 0 init { @@ -57,6 +64,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { requireChildrenUnlocked() setWindowCacheOnChangedChild(event) updateFloatingComponentsOnChangedChild(event) + updateUpdateFuncsOnChangedChild(event) } } @@ -1050,6 +1058,258 @@ abstract class UIComponent : Observable(), ReferenceHolder { } } + //region Public UpdateFunc API + fun addUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: mutableListOf().also { updateFuncs = it } + val index = updateFuncs.size + updateFuncs.add(func) + + val indexInWindow = allocUpdateFuncs(index, 1) + if (indexInWindow != -1) { + cachedWindow!!.allUpdateFuncs[indexInWindow] = func + assertUpdateFuncInvariants() + } + } + + fun removeUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: return + val index = updateFuncs.indexOf(func) + if (index == -1) return + updateFuncs.removeAt(index) + + freeUpdateFuncs(index, 1) + } + //endregion + + //region Internal UpdateFunc tracking + private var updateFuncParent: UIComponent? = null + private var updateFuncs: MutableList? = null // only allocated if used + private var effectUpdateFuncs = 0 // count of effect funcs + private var totalUpdateFuncs = 0 // count of own funcs + effect funcs + children total funcs + + private fun localUpdateFuncIndexForEffect(effectIndex: Int, indexInEffect: Int): Int { + var localIndex = updateFuncs?.size ?: 0 + for ((otherEffectIndex, otherEffect) in effects.withIndex()) { + if (otherEffectIndex >= effectIndex) { + break + } else { + if (otherEffect.updateFuncParent != this) continue // can happen if added to two components at the same time + localIndex += otherEffect.updateFuncs?.size ?: 0 + } + } + localIndex += indexInEffect + return localIndex + } + + private fun localUpdateFuncIndexForChild(childIndex: Int, indexInChild: Int): Int { + var localIndex = (updateFuncs?.size ?: 0) + effectUpdateFuncs + for ((otherChildIndex, otherChild) in children.withIndex()) { + if (otherChildIndex >= childIndex) { + break + } else { + if (otherChild.updateFuncParent != this) continue // can happen if added to two components at the same time + localIndex += otherChild.totalUpdateFuncs + } + } + localIndex += indexInChild + return localIndex + } + + internal fun addUpdateFunc(effect: Effect, indexInEffect: Int, func: UpdateFunc) { + effectUpdateFuncs++ + val indexInWindow = allocUpdateFuncs(localUpdateFuncIndexForEffect(effects.indexOf(effect), indexInEffect), 1) + if (indexInWindow != -1) { + cachedWindow!!.allUpdateFuncs[indexInWindow] = func + assertUpdateFuncInvariants() + } + } + + internal fun removeUpdateFunc(effect: Effect, indexInEffect: Int) { + effectUpdateFuncs-- + freeUpdateFuncs(localUpdateFuncIndexForEffect(effects.indexOf(effect), indexInEffect), 1) + } + + private fun allocUpdateFuncs(childIndex: Int, indexInChild: Int, count: Int): Int { + return allocUpdateFuncs(localUpdateFuncIndexForChild(childIndex, indexInChild), count) + } + + private fun freeUpdateFuncs(childIndex: Int, indexInChild: Int, count: Int) { + freeUpdateFuncs(localUpdateFuncIndexForChild(childIndex, indexInChild), count) + } + + private fun allocUpdateFuncs(localIndex: Int, count: Int): Int { + totalUpdateFuncs += count + if (this is Window) { + if (nextUpdateFuncIndex > localIndex) { + nextUpdateFuncIndex += count + } + if (count == 1) { + allUpdateFuncs.add(localIndex, NOP_UPDATE_FUNC) + } else { + allUpdateFuncs.addAll(localIndex, NopUpdateFuncList(count)) + } + return localIndex + } else { + val parent = updateFuncParent ?: return -1 + return parent.allocUpdateFuncs(parent.children.indexOf(this), localIndex, count) + } + } + + private fun freeUpdateFuncs(localIndex: Int, count: Int) { + totalUpdateFuncs -= count + if (this is Window) { + if (nextUpdateFuncIndex > localIndex) { + nextUpdateFuncIndex -= min(count, nextUpdateFuncIndex - localIndex) + } + if (count == 1) { + allUpdateFuncs.removeAt(localIndex) + } else { + allUpdateFuncs.subList(localIndex, localIndex + count).clear() + } + assertUpdateFuncInvariants() + } else { + val parent = updateFuncParent ?: return + parent.freeUpdateFuncs(parent.children.indexOf(this), localIndex, count) + } + } + + private fun updateUpdateFuncsOnChangedChild(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> { + val (childIndex, child) = event.element + child.updateFuncParent?.let { oldParent -> + oldParent.updateUpdateFuncsOnChangedChild(ObservableRemoveEvent( + IndexedValue(oldParent.children.indexOf(child), child))) + } + assert(child.updateFuncParent == null) + child.updateFuncParent = this + + if (child.totalUpdateFuncs == 0) return + var indexInWindow = allocUpdateFuncs(childIndex, 0, child.totalUpdateFuncs) + if (indexInWindow == -1) return + val allUpdateFuncs = cachedWindow!!.allUpdateFuncs + fun register(component: UIComponent) { + component.updateFuncs?.let { funcs -> + for (func in funcs) { + allUpdateFuncs[indexInWindow++] = func + } + } + component.effects.forEach { effect -> + if (effect.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + effect.updateFuncs?.let { funcs -> + for (func in funcs) { + allUpdateFuncs[indexInWindow++] = func + } + } + } + component.children.forEach { child -> + if (child.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + register(child) + } + } + register(child) + assertUpdateFuncInvariants() + } + is ObservableRemoveEvent -> { + val (childIndex, child) = event.element + if (child.updateFuncParent != this) return // double remove can happen if added to two component at once + child.updateFuncParent = null + + if (child.totalUpdateFuncs == 0) return + freeUpdateFuncs(childIndex, 0, child.totalUpdateFuncs) + } + is ObservableClearEvent -> { + event.oldChildren.forEach { if (it.updateFuncParent == this) it.updateFuncParent = null } + + val remainingFuncs = (updateFuncs?.size ?: 0) + effectUpdateFuncs + val removedFuncs = totalUpdateFuncs - remainingFuncs + freeUpdateFuncs(remainingFuncs, removedFuncs) + } + } + } + + private fun updateUpdateFuncsOnChangedEffect(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> { + val (effectIndex, effect) = event.element + effect.updateFuncParent?.let { oldParent -> + oldParent.updateUpdateFuncsOnChangedEffect(ObservableRemoveEvent( + IndexedValue(oldParent.effects.indexOf(effect), effect))) + } + assert(effect.updateFuncParent == null) + effect.updateFuncParent = this + + val funcs = effect.updateFuncs ?: return + if (funcs.isEmpty()) return + effectUpdateFuncs += funcs.size + var indexInWindow = allocUpdateFuncs(localUpdateFuncIndexForEffect(effectIndex, 0), funcs.size) + if (indexInWindow == -1) return + val allUpdateFuncs = cachedWindow!!.allUpdateFuncs + for (func in funcs) { + allUpdateFuncs[indexInWindow++] = func + } + assertUpdateFuncInvariants() + } + is ObservableRemoveEvent -> { + val (effectIndex, effect) = event.element + if (effect.updateFuncParent != this) return // double remove can happen if added to two component at once + effect.updateFuncParent = null + + val funcs = effect.updateFuncs?.size ?: 0 + if (funcs == 0) return + effectUpdateFuncs -= funcs + freeUpdateFuncs(localUpdateFuncIndexForEffect(effectIndex, 0), funcs) + } + is ObservableClearEvent -> { + event.oldChildren.forEach { if (it.updateFuncParent == this) it.updateFuncParent = null } + + val removedFuncs = effectUpdateFuncs + effectUpdateFuncs = 0 + freeUpdateFuncs(updateFuncs?.size ?: 0, removedFuncs) + } + } + } + + internal fun assertUpdateFuncInvariants() { + if (!ASSERT_UPDATE_FUNC_INVARINTS) return + + val window = cachedWindow ?: return + val allUpdateFuncs = window.allUpdateFuncs + + var indexInWindow = 0 + + fun visit(component: UIComponent) { + val effectUpdateFuncs = component.effects.sumOf { if (it.updateFuncParent == component) it.updateFuncs?.size ?: 0 else 0 } + val childUpdateFuncs = component.children.sumOf { if (it.updateFuncParent == component) it.totalUpdateFuncs else 0 } + assert(component.effectUpdateFuncs == effectUpdateFuncs) + assert(component.totalUpdateFuncs == (component.updateFuncs?.size ?: 0) + effectUpdateFuncs + childUpdateFuncs) + + component.updateFuncs?.let { funcs -> + for (func in funcs) { + assert(func == allUpdateFuncs[indexInWindow++]) + } + } + component.effects.forEach { effect -> + if (effect.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + effect.updateFuncs?.let { funcs -> + for (func in funcs) { + assert(func == allUpdateFuncs[indexInWindow++]) + } + } + } + component.children.forEach { child -> + if (child.updateFuncParent != component) return@forEach // can happen if added to two components at the same time + visit(child) + } + } + visit(window) + + assert(indexInWindow == allUpdateFuncs.size) + } + //endregion + /** * Field animation API */ @@ -1264,6 +1524,8 @@ abstract class UIComponent : Observable(), ReferenceHolder { // Default value for componentName used as marker for lazy init. private val defaultComponentName = String() + private val ASSERT_UPDATE_FUNC_INVARINTS = System.getProperty("elementa.debug.assertUpdateFuncInvariants").toBoolean() + val DEBUG_OUTLINE_WIDTH = System.getProperty("elementa.debug.width")?.toDoubleOrNull() ?: 2.0 /** diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index d7293a51..902495fc 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -23,6 +23,12 @@ class Window @JvmOverloads constructor( val animationFPS: Int = 244 ) : UIComponent() { private var systemTime = -1L + + private var lastDrawTime: Long = -1 + + internal var allUpdateFuncs: MutableList = mutableListOf() + internal var nextUpdateFuncIndex = 0 + private var currentMouseButton = -1 private var legacyFloatingComponents = mutableListOf() @@ -43,6 +49,7 @@ class Window @JvmOverloads constructor( init { super.parent = this + cachedWindow = this } override fun afterInitialization() { @@ -74,9 +81,23 @@ class Window @JvmOverloads constructor( if (systemTime == -1L) systemTime = System.currentTimeMillis() + if (lastDrawTime == -1L) + lastDrawTime = System.currentTimeMillis() + + val now = System.currentTimeMillis() + val dtMs = now - lastDrawTime + lastDrawTime = now try { + assertUpdateFuncInvariants() + nextUpdateFuncIndex = 0 + while (true) { + val func = allUpdateFuncs.getOrNull(nextUpdateFuncIndex) ?: break + nextUpdateFuncIndex++ + func(dtMs / 1000f, dtMs.toInt()) + } + //If this Window is more than 5 seconds behind, reset it be only 5 seconds. //This will drop missed frames but avoid the game freezing as the Window tries //to catch after a period of inactivity diff --git a/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt b/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt new file mode 100644 index 00000000..7c6482fa --- /dev/null +++ b/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt @@ -0,0 +1,19 @@ +package gg.essential.elementa.components + +/** + * Called once at the start of every frame to update any animations and miscellaneous state. + * + * @param dt Time (in seconds) since last frame + * @param dtMs Time (in milliseconds) since last frame + * + * This differs from `(dt / 1000).toInt()` in that it will account for the fractional milliseconds which would + * otherwise be lost to rounding. E.g. if there are three frames each lasting 16.4ms, + * `(dt / 1000).toInt()` would be 16 each time, but `dtMs` will be 16 on the first two frames and 17 on the third. + */ +typealias UpdateFunc = (dt: Float, dtMs: Int) -> Unit + +internal val NOP_UPDATE_FUNC: UpdateFunc = { _, _ -> } + +internal class NopUpdateFuncList(override val size: Int) : AbstractList() { + override fun get(index: Int): UpdateFunc = NOP_UPDATE_FUNC +} diff --git a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt index 6db5e5ce..c8cfc1e0 100644 --- a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt +++ b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt @@ -1,6 +1,7 @@ package gg.essential.elementa.effects import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UpdateFunc import gg.essential.universal.UMatrixStack /** @@ -17,6 +18,26 @@ abstract class Effect { "which already has a bound component") boundComponent = component } + + internal var updateFuncParent: UIComponent? = null + internal var updateFuncs: MutableList? = null // only allocated if used + + protected fun addUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: mutableListOf().also { updateFuncs = it } + updateFuncs.add(func) + + updateFuncParent?.addUpdateFunc(this, updateFuncs.lastIndex, func) + } + + protected fun removeUpdateFunc(func: UpdateFunc) { + val updateFuncs = updateFuncs ?: return + val index = updateFuncs.indexOf(func) + if (index == -1) return + updateFuncs.removeAt(index) + + updateFuncParent?.removeUpdateFunc(this, index) + } + /** * Called once inside of the component's afterInitialization function */ From ae3c5fc79a5a78f2e32f047657c8e0757f6f1b7e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Sat, 22 Feb 2025 17:53:10 +0100 Subject: [PATCH 4/4] UIComponent: Optimize mouseMove and dragMouse methods Previously these would both just traverse the entire tree and evaluate the constraints of all components. In Essential, only components which can be dragged care about drag events, so most components don't, and since we primarily use the `hoveredState` from unstable Elementa, virtually no components need the mouseEnter/Leave listeners (which is what mouseMove is for). This commit thusly optimizes the two methods to only traverse the narrow subtree which has components that might be interested. For simplicity, this is a best-effort optimization (e.g. it may still visit components which have had such listeners at some point but no longer do now); it should however be completely correct in that it will behave idential to how it behaved prior to this commit, just consume less CPU where easily possible. Only exception are the `lastDraggedMouseX/Y` properties which are impossible to accurately emulate without traversing every component. They should really have been private and only in Window to begin with... This commit compromises by accepting that their behaivor may be different now if set manually or if dragMouse is called manually, but still preserves the rough meaning if it's merely read from in e.g. `mouseRelease`. The exact behavior of these properties was sufficiently unexpected that hopefully no one will have tried to rely on their exact behavior to begin with. Note that the Flags class and tracking introduced in this commit also support `Effect`s, despite neither of the two methods being available in `Effect`s. This is because we'll also use the same mechanism with `animationFrame` in the future. --- .../gg/essential/elementa/UIComponent.kt | 157 ++++++++++++++++-- .../essential/elementa/components/Window.kt | 23 ++- .../gg/essential/elementa/effects/Effect.kt | 15 ++ 3 files changed, 175 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index 292a6d57..bc2131c0 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -25,6 +25,7 @@ import gg.essential.universal.UResolution import org.lwjgl.opengl.GL11 import java.awt.Color import java.util.* +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.CopyOnWriteArrayList import java.util.function.BiConsumer @@ -55,6 +56,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { val effects: MutableList = mutableListOf().observable().apply { addObserver { _, event -> updateUpdateFuncsOnChangedEffect(event) + updateEffectFlagsOnChangedEffect(event) } } @@ -65,6 +67,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { setWindowCacheOnChangedChild(event) updateFloatingComponentsOnChangedChild(event) updateUpdateFuncsOnChangedChild(event) + updateCombinedFlagsOnChangedChild(event) } } @@ -80,8 +83,14 @@ abstract class UIComponent : Observable(), ReferenceHolder { notifyObservers(constraints) } - var lastDraggedMouseX: Double? = null - var lastDraggedMouseY: Double? = null + @Deprecated("This property should have been private and probably does not do what you expect it to.") + var lastDraggedMouseX: Double? + get() = Window.ofOrNull(this)?.prevDraggedMouseX?.toDouble() + set(_) {} + @Deprecated("This property should have been private and probably does not do what you expect it to.") + var lastDraggedMouseY: Double? + get() = Window.ofOrNull(this)?.prevDraggedMouseY?.toDouble() + set(_) {} /* Bubbling Events */ var mouseScrollListeners = mutableListOf Unit>() @@ -92,8 +101,11 @@ abstract class UIComponent : Observable(), ReferenceHolder { /* Non-Bubbling Events */ val mouseReleaseListeners = mutableListOf Unit>() val mouseEnterListeners = mutableListOf Unit>() + get() = field.also { ownFlags += Flags.RequiresMouseMove } val mouseLeaveListeners = mutableListOf Unit>() + get() = field.also { ownFlags += Flags.RequiresMouseMove } val mouseDragListeners = mutableListOf Unit>() + get() = field.also { ownFlags += Flags.RequiresMouseDrag } val keyTypedListeners = mutableListOf Unit>() private var currentlyHovered = false @@ -142,6 +154,73 @@ abstract class UIComponent : Observable(), ReferenceHolder { children.forEach { it.recursivelySetWindowCache(window) } } + //region Internal flags tracking + /** Flags which apply to this component specifically. */ + internal var ownFlags = Flags.initialFor(javaClass) + set(newValue) { + val oldValue = field + if (oldValue == newValue) return + field = newValue + if (oldValue in newValue) { // merely additions? + combinedFlags += newValue + } else { + recomputeCombinedFlags() + } + } + /** Flags which apply to one of the effects of tis component. */ + internal var effectFlags = Flags(0u) + set(newValue) { + val oldValue = field + if (oldValue == newValue) return + field = newValue + if (oldValue in newValue) { // merely additions? + combinedFlags += newValue + } else { + recomputeCombinedFlags() + } + } + /** Combined flags of this component, its effects, and its children. */ + internal var combinedFlags = ownFlags + set(newValue) { + val oldValue = field + if (oldValue == newValue) return + field = newValue + if (hasParent && parent != this) { + if (oldValue in newValue) { // merely additions? + parent.combinedFlags += newValue + } else { + parent.recomputeCombinedFlags() + } + } + } + + internal fun recomputeEffectFlags() { + effectFlags = effects.fold(Flags(0u)) { acc, effect -> acc + effect.flags } + } + + private fun recomputeCombinedFlags() { + combinedFlags = children.fold(ownFlags + effectFlags) { acc, child -> acc + child.combinedFlags } + } + + private fun updateEffectFlagsOnChangedEffect(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> effectFlags += event.element.value.flags + is ObservableRemoveEvent -> recomputeEffectFlags() + is ObservableClearEvent -> recomputeEffectFlags() + } + } + + private fun updateCombinedFlagsOnChangedChild(possibleEvent: Any) { + @Suppress("UNCHECKED_CAST") + when (val event = possibleEvent as? ObservableListEvent ?: return) { + is ObservableAddEvent -> combinedFlags += event.element.value.combinedFlags + is ObservableRemoveEvent -> recomputeCombinedFlags() + is ObservableClearEvent -> recomputeCombinedFlags() + } + } + //endregion + protected fun requireChildrenUnlocked() { requireState(childrenLocked == 0, "Cannot modify children while iterating over them.") } @@ -563,6 +642,16 @@ abstract class UIComponent : Observable(), ReferenceHolder { fun beforeChildrenDrawCompat(matrixStack: UMatrixStack) = UMatrixStack.Compat.runLegacyMethod(matrixStack) { beforeChildrenDraw() } open fun mouseMove(window: Window) { + if (Flags.RequiresMouseMove in ownFlags) { + updateCurrentlyHoveredState(window) + } + + if (Flags.RequiresMouseMove in combinedFlags) { + this.forEachChild { it.mouseMove(window) } + } + } + + private fun updateCurrentlyHoveredState(window: Window) { val hovered = isHovered() && window.hoveredFloatingComponent.let { it == null || it == this || isComponentInParentChain(it) } @@ -576,8 +665,6 @@ abstract class UIComponent : Observable(), ReferenceHolder { this.listener() currentlyHovered = false } - - this.forEachChild { it.mouseMove(window) } } /** @@ -588,8 +675,6 @@ abstract class UIComponent : Observable(), ReferenceHolder { open fun mouseClick(mouseX: Double, mouseY: Double, button: Int) { val clicked = hitTest(mouseX.toFloat(), mouseY.toFloat()) - lastDraggedMouseX = mouseX - lastDraggedMouseY = mouseY lastClickCount = if (System.currentTimeMillis() - lastClickTime < 500) lastClickCount + 1 else 1 lastClickTime = System.currentTimeMillis() @@ -626,9 +711,6 @@ abstract class UIComponent : Observable(), ReferenceHolder { for (listener in mouseReleaseListeners) this.listener() - lastDraggedMouseX = null - lastDraggedMouseY = null - this.forEachChild { it.mouseRelease() } } @@ -707,17 +789,17 @@ abstract class UIComponent : Observable(), ReferenceHolder { } private inline fun doDragMouse(mouseX: Float, mouseY: Float, button: Int, superCall: UIComponent.() -> Unit) { - if (lastDraggedMouseX == mouseX.toDouble() && lastDraggedMouseY == mouseY.toDouble()) + if (Flags.RequiresMouseDrag !in combinedFlags) { return + } - lastDraggedMouseX = mouseX.toDouble() - lastDraggedMouseY = mouseY.toDouble() - - val relativeX = mouseX - getLeft() - val relativeY = mouseY - getTop() + if (Flags.RequiresMouseDrag in ownFlags) { + val relativeX = mouseX - getLeft() + val relativeY = mouseY - getTop() - for (listener in mouseDragListeners) - this.listener(relativeX, relativeY, button) + for (listener in mouseDragListeners) + this.listener(relativeX, relativeY, button) + } this.forEachChild { it.superCall() } } @@ -1520,6 +1602,47 @@ abstract class UIComponent : Observable(), ReferenceHolder { return { heldReferences.remove(listener) } } + @JvmInline + internal value class Flags(val bits: UInt) { + operator fun contains(element: Flags) = this.bits and element.bits == element.bits + infix fun and(other: Flags) = Flags(this.bits and other.bits) + infix fun or(other: Flags) = Flags(this.bits or other.bits) + operator fun plus(other: Flags) = this or other + operator fun minus(other: Flags) = Flags(this.bits and other.bits.inv()) + fun inv() = Flags(bits.inv() and All.bits) + + companion object { + private var nextBit = 0 + private val iota: Flags + get() = Flags(1u shl nextBit++) + + val None = Flags(0u) + + val RequiresMouseMove = iota + val RequiresMouseDrag = iota + + val All = Flags(iota.bits - 1u) + + private val cache = ConcurrentHashMap, Flags>().apply { + put(Effect::class.java, Flags(0u)) + put(UIComponent::class.java, Flags(0u)) + put(Window::class.java, Flags(0u)) + } + fun initialFor(cls: Class<*>): Flags = cache.getOrPut(cls) { + flagsBasedOnOverrides(cls) + initialFor(cls.superclass) + } + + private fun flagsBasedOnOverrides(cls: Class<*>): Flags = listOf( + if (cls.overridesMethod("mouseMove", Window::class.java)) RequiresMouseMove else None, + if (cls.overridesMethod("dragMouse", Int::class.java, Int::class.java, Int::class.java)) RequiresMouseDrag else None, + if (cls.overridesMethod("dragMouse", Float::class.java, Float::class.java, Int::class.java)) RequiresMouseDrag else None, + ).reduce { acc, flags -> acc + flags } + + private fun Class<*>.overridesMethod(name: String, vararg args: Class<*>) = + try { getDeclaredMethod(name, *args); true } catch (_: NoSuchMethodException) { false } + } + } + companion object { // Default value for componentName used as marker for lazy init. private val defaultComponentName = String() diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index 902495fc..bb14554c 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -223,6 +223,9 @@ class Window @JvmOverloads constructor( // 2 and over. See [ElementaVersion.V2] for more info. val (adjustedX, adjustedY) = pixelCoordinatesToPixelCenter(mouseX, mouseY) + prevDraggedMouseX = adjustedX.toFloat() + prevDraggedMouseY = adjustedY.toFloat() + doMouseClick(adjustedX, adjustedY, button) } @@ -270,6 +273,9 @@ class Window @JvmOverloads constructor( super.mouseRelease() + prevDraggedMouseX = null + prevDraggedMouseY = null + currentMouseButton = -1 } @@ -291,14 +297,25 @@ class Window @JvmOverloads constructor( } } + internal var prevDraggedMouseX: Float? = null + internal var prevDraggedMouseY: Float? = null + override fun animationFrame() { if (currentMouseButton != -1) { val (mouseX, mouseY) = getMousePosition() if (version >= ElementaVersion.v2) { - dragMouse(mouseX, mouseY, currentMouseButton) + if (prevDraggedMouseX != mouseX && prevDraggedMouseY != mouseY) { + prevDraggedMouseX = mouseX + prevDraggedMouseY = mouseY + dragMouse(mouseX, mouseY, currentMouseButton) + } } else { - @Suppress("DEPRECATION") - dragMouse(mouseX.toInt(), mouseY.toInt(), currentMouseButton) + if (prevDraggedMouseX != mouseX.toInt().toFloat() && prevDraggedMouseY != mouseY.toInt().toFloat()) { + prevDraggedMouseX = mouseX.toInt().toFloat() + prevDraggedMouseY = mouseY.toInt().toFloat() + @Suppress("DEPRECATION") + dragMouse(mouseX.toInt(), mouseY.toInt(), currentMouseButton) + } } } diff --git a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt index c8cfc1e0..05f2fbab 100644 --- a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt +++ b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt @@ -1,6 +1,7 @@ package gg.essential.elementa.effects import gg.essential.elementa.UIComponent +import gg.essential.elementa.UIComponent.Flags import gg.essential.elementa.components.UpdateFunc import gg.essential.universal.UMatrixStack @@ -10,6 +11,20 @@ import gg.essential.universal.UMatrixStack * This is where you can affect any drawing done. */ abstract class Effect { + internal var flags: Flags = Flags.initialFor(javaClass) + set(newValue) { + val oldValue = field + if (oldValue == newValue) return + field = newValue + updateFuncParent?.let { parent -> + if (oldValue in newValue) { // merely additions? + parent.effectFlags += newValue + } else { + parent.recomputeEffectFlags() + } + } + } + protected lateinit var boundComponent: UIComponent fun bindComponent(component: UIComponent) {