From 95b55a6980560735c3497cbaee5ffe0807886e8e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Sat, 22 Feb 2025 10:55:36 +0100 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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) { From 8e34dc579e25d9c6fdb1d7ff582790884543ac14 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 24 Feb 2025 10:58:30 +0100 Subject: [PATCH 5/5] UIComponent: Deprecate `animationFrame` See the kdocs on `ElementaVersion.V8` for details and motivation. Aside from the changes behind the ElementaVersion flag, this commit also changes the behavior of constraints evaluated on components which do not have a window (because we therefore cannot determine the ElementaVersion) to never cache its value. This is necessary because if we were to cache its value in that situation, we wouldn't be able to get it registered in the Window's `cachedConstraints` list, and so it'd never be invalidated again on V8+. The chance of someone relying on the constraint caching behaivor in such a specific scenario are sufficiently tiny that it seems safe to make this change. --- api/Elementa.api | 6 +- .../gg/essential/elementa/ElementaVersion.kt | 76 ++++++++++ .../gg/essential/elementa/UIComponent.kt | 141 ++++++++++++++++-- .../gg/essential/elementa/UIConstraints.kt | 2 + .../elementa/components/ScrollComponent.kt | 23 +-- .../essential/elementa/components/Window.kt | 77 ++++++++-- .../components/input/AbstractTextInput.kt | 11 ++ .../components/inspector/InfoBlockNode.kt | 8 +- .../components/inspector/InspectorNode.kt | 6 +- .../elementa/components/updateFunc.kt | 24 +++ .../constraints/AdditiveConstraint.kt | 2 + .../constraints/CoercionConstraint.kt | 6 + .../elementa/constraints/ColorConstraints.kt | 13 +- .../elementa/constraints/Constraint.kt | 110 +++++--------- .../elementa/constraints/MaxConstraint.kt | 2 + .../elementa/constraints/MinConstraint.kt | 2 + .../elementa/constraints/ScaleConstraint.kt | 2 + .../constraints/SubtractiveConstraint.kt | 2 + .../animation/AnimatingConstraints.kt | 41 +++-- .../animation/AnimationComponent.kt | 48 +++++- .../debug/NoopConstraintDebugger.kt | 8 +- .../gg/essential/elementa/effects/Effect.kt | 1 + .../elementa/effects/RecursiveFadeEffect.kt | 2 + .../elementa/markdown/MarkdownComponent.kt | 12 +- 24 files changed, 478 insertions(+), 147 deletions(-) diff --git a/api/Elementa.api b/api/Elementa.api index e77bd466..ec0f074a 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -8,6 +8,7 @@ public final class gg/essential/elementa/ElementaVersion : java/lang/Enum { public static final field V5 Lgg/essential/elementa/ElementaVersion; public static final field V6 Lgg/essential/elementa/ElementaVersion; public static final field V7 Lgg/essential/elementa/ElementaVersion; + public static final field V8 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; @@ -362,7 +363,6 @@ public final class gg/essential/elementa/components/ScrollComponent : gg/essenti public synthetic fun addChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/UIComponent; public fun addChild (Lgg/essential/elementa/UIComponent;)Lgg/essential/elementa/components/ScrollComponent; public final fun addScrollAdjustEvent (ZLkotlin/jvm/functions/Function2;)V - public fun afterInitialization ()V public fun alwaysDrawChildren ()Z public fun animationFrame ()V public fun childrenOfType (Ljava/lang/Class;)Ljava/util/List; @@ -756,6 +756,8 @@ public final class gg/essential/elementa/components/Window : gg/essential/elemen public final fun drawFloatingComponents (Lgg/essential/universal/UMatrixStack;)V public final fun focus (Lgg/essential/elementa/UIComponent;)V public final fun getAnimationFPS ()I + public final fun getAnimationTimeMs ()J + public final fun getAnimationTimeNs ()J public fun getBottom ()F public final fun getFocusedComponent ()Lgg/essential/elementa/UIComponent; public final fun getHasErrored ()Z @@ -766,6 +768,7 @@ public final class gg/essential/elementa/components/Window : gg/essential/elemen public fun getTop ()F public fun getWidth ()F public fun hitTest (FF)Lgg/essential/elementa/UIComponent; + public final fun invalidateCachedConstraints ()V public final fun isAreaVisible (DDDD)Z public fun keyType (CI)V public fun mouseClick (DDI)V @@ -1869,7 +1872,6 @@ public final class gg/essential/elementa/constraints/RainbowColorConstraint : gg public fun ()V public fun (IF)V public synthetic fun (IFILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun animationFrame ()V public final fun getAlpha ()I public fun getCachedValue ()Ljava/awt/Color; public synthetic fun getCachedValue ()Ljava/lang/Object; diff --git a/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt b/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt index e63c2687..8df41a71 100644 --- a/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt +++ b/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt @@ -1,5 +1,11 @@ package gg.essential.elementa +import gg.essential.elementa.components.UpdateFunc +import gg.essential.elementa.components.Window +import gg.essential.elementa.constraints.SuperConstraint +import gg.essential.elementa.constraints.animation.AnimationComponent +import gg.essential.elementa.effects.Effect + /** * Sometimes it is necessary or desirable to introduce breaking behavioral changes to Elementa. In order to maintain * full backwards compatibility in these cases, library consumers must explicitly opt-in to such changes for their @@ -96,8 +102,76 @@ enum class ElementaVersion { /** * [gg.essential.elementa.components.Window] now disables input events if an error has occurred during drawing. */ + @Deprecated(DEPRECATION_MESSAGE) V7, + /** + * The [animationFrame][UIComponent.animationFrame] methods are now deprecated and will no longer be called at all + * for [constraints][SuperConstraint.animationFrame] or if your override is marked as [Deprecated]. + * The relative order in which various things ([UpdateFunc]s, constraint cache invalidation, [UIComponent] timers + * and field animations, [UIComponent.animationFrame], and [Effect.animationFrame]) will be called has changed + * because constraint cache invalidation is separate now and [UpdateFunc]s are used internally for timers and field + * animations now. + * + * All custom constraints which currently rely on `animationFrame` must be updated to support the new + * `animationTime` mechanism described below before this version can be enabled! + * + * All custom components and effects which override `animationFrame` should be updated to use the [UpdateFunc] API + * instead, and some may also require updates to account for the change in relative order mentioned above. + * Note however that both the UpdateFunc mechanism and the animationTime properties are both available on any + * [ElementaVersion], so most (if not all) of your components can migrate to them even before opting to enable + * this [ElementaVersion]. + * + * If your custom component or effect needs to update some animation or other miscellaneous state before each frame, + * use the [UpdateFunc] mechanism instead (via [UIComponent.addUpdateFunc]/[Effect.addUpdateFunc]). + * This way, only components which actually have something that needs updating will need to be called each frame. + * + * If your custom component or effect needs to continue to support older [ElementaVersion]s, ideally mark your + * `animationFrame` override as [Deprecated], which will allow Elementa to no longer call it on newer versions. + * If it is not annotated, Elementa will continue to call it and pay the corresponding performance penalty to do so. + * + * If your custom constraint is animated, use [Window.animationTimeNs]/[animationTimeMs][Window.animationTimeMs] + * to drive that animation instead. + * + * You no longer need to call [SuperConstraint.animationFrame] to cause the cached value in a constraint to be + * recomputed each frame. Constraints will now automatically register themselves with the [Window] they are + * evaluated on, so it can invalidate them automatically via [Window.invalidateCachedConstraints]. + * This can be done manually any number of times during one frame and will be called by default at least twice per + * frame (once before all update funcs and once after). + * + * + * Additionally, given both new mechanisms are variable time, [Window.animationFPS] is now deprecated and the + * meaning of any existing `frames` parameter which are used for timing and cannot be renamed without breaking ABI + * (e.g. [AnimationComponent.elapsedFrames]) is changed to now mean "milliseconds" instead. + * + * + * The main reasons for this change are: + * - Previously it was not possible to get layout information from a component, then update it depending on that + * information and still have that update be reflected in the current frame, because there was no safe way to + * invalidate the cached layout information. You would either have to call `animationFrame` and accept some + * animations running quicker than intended, or wait until the next frame. + * - A common beginner mistake was to query layout information during `animationFrame`, during that method however + * usually parts of the tree still have the old values cached, so evaluating the layout could result in those old + * values being used while computing the new values. Now that the two operations are separate, it is safe to query + * the layout during [UpdateFunc]s because `invalidateCachedConstraints` will be called again afterwards. + * And if you change the layout in response to your measurements, you can call the method yourself to immediately + * make visible those changes to all remaining [UpdateFunc]s too. + * - Another common mistake was making changes to the component hierarchy from `animationFrame`. Given that method + * is called from a trivial tree traversal, making changes to that tree could result in + * ConcurrentModificationExceptions (or the custom "Cannot modify children while iterating over them." exception). + * The [UpdateFunc] implementation does not suffer from this restriction. + * - `animationFrame` runs on a fixed update rate, which almost certainly won't match the real frame rate perfectly + * and will result in multiple calls per frame (by default 244 times per second), which not only wastes cpu time + * but also results in slow motion animations if there isn't enough time for all the calls. + * Since we mostly use this for animations, and not physics simulations, using variable rate updates is not really + * any more difficult (in some cases it's actually easier) and solves both of these. + * - `animationFrame` will traverse the entire tree, even if an entire branch has neither things that need regular + * updates nor had its constraints evaluated (e.g. because it's off-screen). + * The new constraint tracking will only invalidate constraints which were evaluated, and the [UpdateFunc]s + * are tracked intelligently at registration, such that no more full tree traversals should be necessary. + */ + V8, + ; /** @@ -142,7 +216,9 @@ Be sure to read through all the changes between your current version and your ne internal val v5 = V5 @Suppress("DEPRECATION") internal val v6 = V6 + @Suppress("DEPRECATION") internal val v7 = V7 + internal val v8 = V8 @PublishedApi diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index bc2131c0..5c4387f7 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -78,6 +78,18 @@ abstract class UIComponent : Observable(), ReferenceHolder { var constraints = UIConstraints(this) set(value) { + (field as? AnimatingConstraints)?.updateFunc?.let { removeUpdateFunc(it) } + if (value is AnimatingConstraints) { + addUpdateFunc(object : UpdateFunc { + override fun invoke(dt: Float, dtMs: Int) { + if (Window.of(this@UIComponent).version < ElementaVersion.v8) { + removeUpdateFunc(this) // handled by `animationFrame` + return + } + value.updateCompletion(dtMs) + } + }.also { value.updateFunc = it }) + } field = value setChanged() notifyObservers(constraints) @@ -138,6 +150,9 @@ abstract class UIComponent : Observable(), ReferenceHolder { private var didCallBeforeDraw = false private var warnedAboutBeforeDraw = false + internal val versionOrV0: ElementaVersion + get() = Window.ofOrNull(this)?.version ?: ElementaVersion.v0 + internal var cachedWindow: Window? = null private fun setWindowCacheOnChangedChild(possibleEvent: Any) { @@ -809,7 +824,38 @@ abstract class UIComponent : Observable(), ReferenceHolder { this.listener(typedChar, keyCode) } + @Deprecated("See [ElementaVersion.V8].") open fun animationFrame() { + if (versionOrV0 >= ElementaVersion.v8) { + doSparseAnimationFrame() + } else { + doLegacyAnimationFrame() + } + } + + private fun doSparseAnimationFrame() { + if (Flags.RequiresAnimationFrame in effectFlags) { + for (effect in effects) { + if (Flags.RequiresAnimationFrame in effect.flags) { + @Suppress("DEPRECATION") + effect.animationFrame() + } + } + } + for (child in children) { + if (Flags.RequiresAnimationFrame in child.combinedFlags) { + if (Flags.RequiresAnimationFrame in child.ownFlags) { + @Suppress("DEPRECATION") + child.animationFrame() + } else { + child.doSparseAnimationFrame() + } + } + } + } + + @Suppress("DEPRECATION") + private fun doLegacyAnimationFrame() { constraints.animationFrame() effects.forEach(Effect::animationFrame) @@ -824,14 +870,19 @@ abstract class UIComponent : Observable(), ReferenceHolder { } // Process timers - val timerIterator = activeTimers.iterator() - timerIterator.forEachRemaining { (id, timer) -> - if (id in stoppedTimers) - return@forEachRemaining - + updateTimers { timer -> val time = System.currentTimeMillis() + if (timer.lastTime == -1L) timer.lastTime = time timer.timeLeft -= (time - timer.lastTime) timer.lastTime = time + } + } + + private inline fun updateTimers(advance: (Timer) -> Unit) { + for ((id, timer) in activeTimers) { + if (id in stoppedTimers) continue + + advance(timer) if (!timer.hasDelayed && timer.timeLeft <= 0L) { timer.hasDelayed = true @@ -1405,9 +1456,10 @@ abstract class UIComponent : Observable(), ReferenceHolder { return } - val totalFrames = (time * Window.of(this@UIComponent).animationFPS).toInt() - val totalDelay = (delay * Window.of(this@UIComponent).animationFPS).toInt() + val totalFrames = (time * Window.of(this@UIComponent).animationFPSOr1000).toInt() + val totalDelay = (delay * Window.of(this@UIComponent).animationFPSOr1000).toInt() + scheduleFieldAnimationUpdateFunc() fieldAnimationQueue.removeIf { it.field == this } fieldAnimationQueue.addFirst( IntFieldAnimationComponent( @@ -1430,9 +1482,10 @@ abstract class UIComponent : Observable(), ReferenceHolder { return } - val totalFrames = (time * Window.of(this@UIComponent).animationFPS).toInt() - val totalDelay = (delay * Window.of(this@UIComponent).animationFPS).toInt() + val totalFrames = (time * Window.of(this@UIComponent).animationFPSOr1000).toInt() + val totalDelay = (delay * Window.of(this@UIComponent).animationFPSOr1000).toInt() + scheduleFieldAnimationUpdateFunc() fieldAnimationQueue.removeIf { it.field == this } fieldAnimationQueue.addFirst( FloatFieldAnimationComponent( @@ -1455,9 +1508,10 @@ abstract class UIComponent : Observable(), ReferenceHolder { return } - val totalFrames = (time * Window.of(this@UIComponent).animationFPS).toInt() - val totalDelay = (delay * Window.of(this@UIComponent).animationFPS).toInt() + val totalFrames = (time * Window.of(this@UIComponent).animationFPSOr1000).toInt() + val totalDelay = (delay * Window.of(this@UIComponent).animationFPSOr1000).toInt() + scheduleFieldAnimationUpdateFunc() fieldAnimationQueue.removeIf { it.field == this } fieldAnimationQueue.addFirst( LongFieldAnimationComponent( @@ -1485,9 +1539,10 @@ abstract class UIComponent : Observable(), ReferenceHolder { return } - val totalFrames = (time * Window.of(this@UIComponent).animationFPS).toInt() - val totalDelay = (delay * Window.of(this@UIComponent).animationFPS).toInt() + val totalFrames = (time * Window.of(this@UIComponent).animationFPSOr1000).toInt() + val totalDelay = (delay * Window.of(this@UIComponent).animationFPSOr1000).toInt() + scheduleFieldAnimationUpdateFunc() fieldAnimationQueue.removeIf { it.field == this } fieldAnimationQueue.addFirst( DoubleFieldAnimationComponent( @@ -1510,9 +1565,10 @@ abstract class UIComponent : Observable(), ReferenceHolder { return } - val totalFrames = (time * Window.of(this@UIComponent).animationFPS).toInt() - val totalDelay = (delay * Window.of(this@UIComponent).animationFPS).toInt() + val totalFrames = (time * Window.of(this@UIComponent).animationFPSOr1000).toInt() + val totalDelay = (delay * Window.of(this@UIComponent).animationFPSOr1000).toInt() + scheduleFieldAnimationUpdateFunc() fieldAnimationQueue.removeIf { it.field == this } fieldAnimationQueue.addFirst( ColorFieldAnimationComponent( @@ -1530,6 +1586,36 @@ abstract class UIComponent : Observable(), ReferenceHolder { fieldAnimationQueue.removeIf { it.field == this } } + private fun scheduleFieldAnimationUpdateFunc() { + if (fieldAnimationQueue.isNotEmpty()) return // should already be scheduled + + addUpdateFunc(object : UpdateFunc { + override fun invoke(dt: Float, dtMs: Int) { + if (Window.of(this@UIComponent).version < ElementaVersion.v8) { + // Field animations will be handled via `animationFrame` + removeUpdateFunc(this) + return + } + + val queueIterator = fieldAnimationQueue.iterator() + queueIterator.forEachRemaining { anim -> + if (!anim.animationPaused) { + anim.elapsedFrames += dtMs + } + anim.setValue(anim.getPercentComplete()) + + if (anim.isComplete()) { + queueIterator.remove() + } + } + + if (fieldAnimationQueue.isEmpty()) { + removeUpdateFunc(this) + } + } + }) + } + private fun validateAnimationFields(time: Float, delay: Float): Boolean { if (time < 0f) { println("time parameter of field animation call cannot be less than 0") @@ -1558,6 +1644,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { */ fun startTimer(interval: Long, delay: Long = 0, callback: (Int) -> Unit): Int { + scheduleTimerUpdateFunc() val id = nextTimerId++ activeTimers[id] = Timer(delay, interval, callback) return id @@ -1587,7 +1674,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { private class Timer(delay: Long, val interval: Long, val callback: (Int) -> Unit) { var hasDelayed = false var timeLeft = delay - var lastTime = System.currentTimeMillis() + var lastTime: Long = -1 // used only with `animationFrame` / pre-v8 init { if (delay == 0L) { @@ -1597,6 +1684,26 @@ abstract class UIComponent : Observable(), ReferenceHolder { } } + private fun scheduleTimerUpdateFunc() { + if (activeTimers.isNotEmpty()) return // should already be scheduled + + addUpdateFunc(object : UpdateFunc { + override fun invoke(dt: Float, dtMs: Int) { + if (Window.of(this@UIComponent).version < ElementaVersion.v8) { + // Timers will be handled via `animationFrame` + removeUpdateFunc(this) + return + } + + updateTimers { it.timeLeft -= dtMs } + + if (activeTimers.isEmpty()) { + removeUpdateFunc(this) + } + } + }) + } + override fun holdOnto(listener: Any): () -> Unit { heldReferences.add(listener) return { heldReferences.remove(listener) } @@ -1620,6 +1727,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { val RequiresMouseMove = iota val RequiresMouseDrag = iota + val RequiresAnimationFrame = iota // only applies when ElementaVersion >= V8 val All = Flags(iota.bits - 1u) @@ -1636,6 +1744,7 @@ abstract class UIComponent : Observable(), ReferenceHolder { 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, + if (cls.overridesMethod("animationFrame")) RequiresAnimationFrame else None, ).reduce { acc, flags -> acc + flags } private fun Class<*>.overridesMethod(name: String, vararg args: Class<*>) = diff --git a/src/main/kotlin/gg/essential/elementa/UIConstraints.kt b/src/main/kotlin/gg/essential/elementa/UIConstraints.kt index 5c858c8f..10f1cea8 100644 --- a/src/main/kotlin/gg/essential/elementa/UIConstraints.kt +++ b/src/main/kotlin/gg/essential/elementa/UIConstraints.kt @@ -111,6 +111,8 @@ open class UIConstraints(protected val component: UIComponent) : Observable() { color = constraint } + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") internal open fun animationFrame() { x.animationFrame() y.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt b/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt index d67c829c..3caff34f 100644 --- a/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt @@ -2,6 +2,7 @@ package gg.essential.elementa.components import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.UpdateFunc import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.Animations import gg.essential.elementa.constraints.resolution.ConstraintVisitor @@ -78,8 +79,6 @@ class ScrollComponent constructor( private val verticalScrollEnabled get() = primaryScrollDirection == Direction.Vertical || secondaryScrollDirection == Direction.Vertical - private var animationFPS: Int? = null - private val actualHolder = UIContainer().constrain { x = innerPadding.pixels() y = innerPadding.pixels() @@ -233,12 +232,6 @@ class ScrollComponent constructor( super.draw(matrixStack) } - override fun afterInitialization() { - super.afterInitialization() - - animationFPS = Window.of(this).animationFPS - } - /** * Sets the text that appears when no items are shown */ @@ -551,11 +544,18 @@ class ScrollComponent constructor( } } + init { addUpdateFuncOnV8ReplacingAnimationFrame { dt, _ -> doUpdate(dt) } } + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() + if (versionOrV0 >= ElementaVersion.v8) return // handled by UpdateFunc + doUpdate(1f / (Window.ofOrNull(this)?.animationFPS ?: 244)) + } + private fun doUpdate(dt: Float) { currentScrollAcceleration = - (currentScrollAcceleration - ((scrollAcceleration - 1.0f) / (animationFPS ?: 244).toFloat())) + (currentScrollAcceleration - (scrollAcceleration - 1.0f) * dt) .coerceAtLeast(1.0f) if (!isAutoScrolling) return @@ -567,7 +567,7 @@ class ScrollComponent constructor( if (currentX in getLeft()..getRight()) { val deltaX = currentX - xBegin val percentX = deltaX / (-getWidth() / 2) - horizontalOffset += (percentX.toFloat() * 5f) + horizontalOffset += (percentX.toFloat() * 5f * 244 * dt) needsUpdate = true } } @@ -579,7 +579,7 @@ class ScrollComponent constructor( if (currentY in getTop()..getBottom()) { val deltaY = currentY - yBegin val percentY = deltaY / (-getHeight() / 2) - verticalOffset += (percentY.toFloat() * 5f) + verticalOffset += (percentY.toFloat() * 5f * 244 * dt) needsUpdate = true } } @@ -820,6 +820,7 @@ class ScrollComponent constructor( override var recalculate: Boolean = true override var constrainTo: UIComponent? = null + @Suppress("OVERRIDE_DEPRECATION", "DEPRECATION") override fun animationFrame() { super.animationFrame() desiredSize.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt index bb14554c..b9da3d76 100644 --- a/src/main/kotlin/gg/essential/elementa/components/Window.kt +++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt @@ -2,6 +2,7 @@ package gg.essential.elementa.components import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent +import gg.essential.elementa.constraints.SuperConstraint import gg.essential.elementa.constraints.resolution.ConstraintResolutionGui import gg.essential.elementa.constraints.resolution.ConstraintResolver import gg.essential.elementa.constraints.resolution.ConstraintResolverV2 @@ -20,15 +21,21 @@ import java.util.concurrent.TimeUnit */ class Window @JvmOverloads constructor( internal val version: ElementaVersion, + @Deprecated("See [ElementaVersion.V8].") val animationFPS: Int = 244 ) : UIComponent() { - private var systemTime = -1L - + private var legacyAnimationFrameTime = -1L private var lastDrawTime: Long = -1 + var animationTimeNs: Long = 0 + private set + val animationTimeMs: Long + get() = animationTimeNs / 1_000_000 internal var allUpdateFuncs: MutableList = mutableListOf() internal var nextUpdateFuncIndex = 0 + internal val cachedConstraints: MutableList> = mutableListOf() + private var currentMouseButton = -1 private var legacyFloatingComponents = mutableListOf() @@ -79,8 +86,8 @@ class Window @JvmOverloads constructor( it.remove() } - if (systemTime == -1L) - systemTime = System.currentTimeMillis() + if (legacyAnimationFrameTime == -1L) + legacyAnimationFrameTime = System.currentTimeMillis() if (lastDrawTime == -1L) lastDrawTime = System.currentTimeMillis() @@ -88,8 +95,20 @@ class Window @JvmOverloads constructor( val dtMs = now - lastDrawTime lastDrawTime = now + animationTimeNs += dtMs * 1_000_000 + try { + if (version >= ElementaVersion.v8) { + dispatchMouseDragging() + + if (componentRequestingFocus != null) { + dealWithFocusRequests() + } + + invalidateCachedConstraints() + } + assertUpdateFuncInvariants() nextUpdateFuncIndex = 0 while (true) { @@ -98,14 +117,20 @@ class Window @JvmOverloads constructor( func(dtMs / 1000f, dtMs.toInt()) } + if (version >= ElementaVersion.v8) { + invalidateCachedConstraints() + } + //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 - if (System.currentTimeMillis() - this.systemTime > TimeUnit.SECONDS.toMillis(5)) - this.systemTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(5) + if (System.currentTimeMillis() - this.legacyAnimationFrameTime > TimeUnit.SECONDS.toMillis(5)) + this.legacyAnimationFrameTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(5) + @Suppress("DEPRECATION") + val animationFPS = animationFPS val target = System.currentTimeMillis() + 1000 / animationFPS - val animationFrames = (target - this.systemTime).toInt() * animationFPS / 1000 + val animationFrames = (target - this.legacyAnimationFrameTime).toInt() * animationFPS / 1000 // If the window is sufficiently complex, it's possible for the average `animationFrame` to take so long // we'll start falling behind with no way to ever catch up. And the amount of frames we're behind will // quickly grow to the point where we'll be spending five seconds in `animationFrame` before we can get a @@ -113,8 +138,13 @@ class Window @JvmOverloads constructor( // To prevent that, we limit the `animationFrame` calls we make per real frame such that we'll still be able // to render approximately 30 real frames per second at the cost of animations slowing down. repeat(animationFrames.coerceAtMost((animationFPS / 30).coerceAtLeast(1))) { + @Suppress("DEPRECATION") animationFrame() - this.systemTime += 1000 / animationFPS + this.legacyAnimationFrameTime += 1000 / animationFPS + + if (version >= ElementaVersion.v8) { + invalidateCachedConstraints() + } } hoveredFloatingComponent = null @@ -300,7 +330,7 @@ class Window @JvmOverloads constructor( internal var prevDraggedMouseX: Float? = null internal var prevDraggedMouseY: Float? = null - override fun animationFrame() { + private fun dispatchMouseDragging() { if (currentMouseButton != -1) { val (mouseX, mouseY) = getMousePosition() if (version >= ElementaVersion.v2) { @@ -318,6 +348,21 @@ class Window @JvmOverloads constructor( } } } + } + + @Deprecated("See [ElementaVersion.V8].") + override fun animationFrame() { + if (version >= ElementaVersion.v8) { + // In v8, dragging and focus is handled before the UpdateFunc calls, we only need to call super to support + // components or effects which may still use animationFrame. + if (Flags.RequiresAnimationFrame in combinedFlags) { + @Suppress("DEPRECATION") + super.animationFrame() + } + return + } + + dispatchMouseDragging() if (componentRequestingFocus != null && componentRequestingFocus != focusedComponent) { if (focusedComponent != null) @@ -328,9 +373,19 @@ class Window @JvmOverloads constructor( } componentRequestingFocus = null + @Suppress("DEPRECATION") super.animationFrame() } + // Note: Constraints are cached this way only with ElementaVersion.V8 and above, + // prior version require calling `animationFrame` which may have additional side-effects. + fun invalidateCachedConstraints() { + for (constraint in cachedConstraints) { + constraint.recalculate = true + } + cachedConstraints.clear() + } + override fun getLeft(): Float { return 0f } @@ -443,6 +498,10 @@ class Window @JvmOverloads constructor( focusedComponent = null } + @Suppress("DEPRECATION") + internal val animationFPSOr1000: Int + get() = if (version >= ElementaVersion.v8) 1000 else animationFPS + companion object { private val renderOperations = ConcurrentLinkedQueue<() -> Unit>() 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 7aff6519..9f85f7ee 100644 --- a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt +++ b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt @@ -1,7 +1,11 @@ package gg.essential.elementa.components.input +import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent import gg.essential.elementa.components.UIBlock +import gg.essential.elementa.components.UpdateFunc +import gg.essential.elementa.components.Window +import gg.essential.elementa.components.addUpdateFuncOnV8ReplacingAnimationFrame import gg.essential.elementa.constraints.CenterConstraint import gg.essential.elementa.constraints.animation.Animations import gg.essential.elementa.dsl.* @@ -738,9 +742,16 @@ abstract class AbstractTextInput( } } + init { addUpdateFuncOnV8ReplacingAnimationFrame { _, _ -> update() } } + @Deprecated("See [ElementaVersion.V8].") override fun animationFrame() { + @Suppress("DEPRECATION") super.animationFrame() + if (versionOrV0 >= ElementaVersion.v8) return // handled by UpdateFunc + update() + } + private fun update() { val diff = (targetVerticalScrollingOffset - verticalScrollingOffset) * 0.1f if (abs(diff) < .25f) verticalScrollingOffset = targetVerticalScrollingOffset diff --git a/src/main/kotlin/gg/essential/elementa/components/inspector/InfoBlockNode.kt b/src/main/kotlin/gg/essential/elementa/components/inspector/InfoBlockNode.kt index 80a245e7..b38d0158 100644 --- a/src/main/kotlin/gg/essential/elementa/components/inspector/InfoBlockNode.kt +++ b/src/main/kotlin/gg/essential/elementa/components/inspector/InfoBlockNode.kt @@ -1,9 +1,11 @@ package gg.essential.elementa.components.inspector +import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent import gg.essential.elementa.components.TreeNode import gg.essential.elementa.components.UIContainer import gg.essential.elementa.components.UIText +import gg.essential.elementa.components.Window import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.AnimationComponent import gg.essential.elementa.dsl.childOf @@ -75,9 +77,11 @@ class InfoBlockNode(private val constraint: SuperConstraint, private val n } childOf stringHolder } - override fun animationFrame() { - super.animationFrame() + init { + addUpdateFunc { _, _ -> update() } + } + private fun update() { if (constraint is AnimationComponent<*>) { val strings = stringHolder.childrenOfType() val percentComplete = constraint.elapsedFrames.toFloat() / (constraint.totalFrames + constraint.delayFrames) diff --git a/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt b/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt index 070c7a22..1a330a01 100644 --- a/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt +++ b/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt @@ -23,9 +23,11 @@ class InspectorNode(private val inspector: Inspector, val targetComponent: UICom width = TextAspectConstraint() } childOf this - override fun animationFrame() { - super.animationFrame() + init { + addUpdateFunc { _, _ -> update() } + } + private fun update() { val isCurrentlyHidden = targetComponent.parent != targetComponent && !targetComponent.parent.children.contains( targetComponent ) diff --git a/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt b/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt index 7c6482fa..7c063a32 100644 --- a/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt +++ b/src/main/kotlin/gg/essential/elementa/components/updateFunc.kt @@ -1,5 +1,9 @@ package gg.essential.elementa.components +import gg.essential.elementa.ElementaVersion +import gg.essential.elementa.UIComponent +import gg.essential.elementa.UIComponent.Flags + /** * Called once at the start of every frame to update any animations and miscellaneous state. * @@ -17,3 +21,23 @@ internal val NOP_UPDATE_FUNC: UpdateFunc = { _, _ -> } internal class NopUpdateFuncList(override val size: Int) : AbstractList() { override fun get(index: Int): UpdateFunc = NOP_UPDATE_FUNC } + +/** + * Internal utility for components which used to use `animationFrame`, and therefore still have to do that for backwards + * compatibility until v8 is enabled, but which then use UpdateFunc once v8 is enabled. + */ +internal fun UIComponent.addUpdateFuncOnV8ReplacingAnimationFrame(func: UpdateFunc) { + // we override animationFrame only for backwards compatibility and use this UpdateFunc on newer versions + ownFlags -= Flags.RequiresAnimationFrame + + addUpdateFunc(object : UpdateFunc { + override fun invoke(dt: Float, dtMs: Int) { + if (Window.of(this@addUpdateFuncOnV8ReplacingAnimationFrame).version < ElementaVersion.v8) { + // handled by animationFrame + removeUpdateFunc(this) + return + } + func(dt, dtMs) + } + }) +} diff --git a/src/main/kotlin/gg/essential/elementa/constraints/AdditiveConstraint.kt b/src/main/kotlin/gg/essential/elementa/constraints/AdditiveConstraint.kt index e290817f..d0f19be3 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/AdditiveConstraint.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/AdditiveConstraint.kt @@ -11,6 +11,8 @@ class AdditiveConstraint( override var recalculate = true override var constrainTo: UIComponent? = null + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() constraint1.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/constraints/CoercionConstraint.kt b/src/main/kotlin/gg/essential/elementa/constraints/CoercionConstraint.kt index 2c686acf..299e22e6 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/CoercionConstraint.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/CoercionConstraint.kt @@ -14,6 +14,8 @@ class CoerceAtMostConstraint( override var recalculate = true override var constrainTo: UIComponent? = null + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() constraint.animationFrame() @@ -66,6 +68,8 @@ class CoerceAtLeastConstraint( override var recalculate = true override var constrainTo: UIComponent? = null + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() constraint.animationFrame() @@ -119,6 +123,8 @@ class CoerceInConstraint( override var recalculate = true override var constrainTo: UIComponent? = null + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() constraint.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/constraints/ColorConstraints.kt b/src/main/kotlin/gg/essential/elementa/constraints/ColorConstraints.kt index bbea4035..f394c997 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/ColorConstraints.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/ColorConstraints.kt @@ -1,6 +1,7 @@ package gg.essential.elementa.constraints import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window import gg.essential.elementa.constraints.resolution.ConstraintVisitor import gg.essential.elementa.state.BasicState import gg.essential.elementa.state.State @@ -90,22 +91,16 @@ class RainbowColorConstraint(val alpha: Int = 255, val speed: Float = 50f) : Col override var recalculate = true override var constrainTo: UIComponent? = null - private var currentColor: Color = Color.WHITE - private var currentStep = Random.nextInt(500) + private val randomOffset = Random.nextInt(500).toFloat() override fun getColorImpl(component: UIComponent): Color { - return currentColor - } - - override fun animationFrame() { - super.animationFrame() - currentStep++ + val currentStep = randomOffset + (Window.ofOrNull(component)?.animationTimeMs ?: 0) * (244 / 1000f) val red = ((sin((currentStep / speed).toDouble()) + 0.75) * 170).toInt() val green = ((sin(currentStep / speed + 2 * Math.PI / 3) + 0.75) * 170).toInt() val blue = ((sin(currentStep / speed + 4 * Math.PI / 3) + 0.75) * 170).toInt() - currentColor = Color( + return Color( red.coerceIn(0, 255), green.coerceIn(0, 255), blue.coerceIn(0, 255), diff --git a/src/main/kotlin/gg/essential/elementa/constraints/Constraint.kt b/src/main/kotlin/gg/essential/elementa/constraints/Constraint.kt index db402196..fd263565 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/Constraint.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/Constraint.kt @@ -1,6 +1,8 @@ package gg.essential.elementa.constraints +import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window import gg.essential.elementa.constraints.animation.AnimationComponent import gg.essential.elementa.constraints.debug.constraintDebugger import gg.essential.elementa.constraints.resolution.ConstraintVisitor @@ -18,6 +20,7 @@ interface SuperConstraint { var recalculate: Boolean var constrainTo: UIComponent? + @Deprecated("See [ElementaVersion.V8].") fun animationFrame() { recalculate = true } @@ -72,37 +75,15 @@ interface PositionConstraint : XConstraint, YConstraint interface XConstraint : SuperConstraint { fun getXPositionImpl(component: UIComponent): Float - fun getXPosition(component: UIComponent): Float { - val debugger = constraintDebugger - if (debugger != null) { - return debugger.evaluate(this, ConstraintType.X, component) - } - - if (recalculate) { - cachedValue = getXPositionImpl(component).roundToRealPixels() - recalculate = false - } - - return cachedValue - } + fun getXPosition(component: UIComponent): Float = + getCachedDebuggable(component, ConstraintType.X) { getXPositionImpl(it).roundToRealPixels() } } interface YConstraint : SuperConstraint { fun getYPositionImpl(component: UIComponent): Float - fun getYPosition(component: UIComponent): Float { - val debugger = constraintDebugger - if (debugger != null) { - return debugger.evaluate(this, ConstraintType.Y, component) - } - - if (recalculate) { - cachedValue = getYPositionImpl(component).roundToRealPixels() - recalculate = false - } - - return cachedValue - } + fun getYPosition(component: UIComponent): Float = + getCachedDebuggable(component, ConstraintType.Y) { getYPositionImpl(it).roundToRealPixels() } } interface SizeConstraint : WidthConstraint, HeightConstraint, RadiusConstraint @@ -110,55 +91,22 @@ interface SizeConstraint : WidthConstraint, HeightConstraint, RadiusConstraint interface RadiusConstraint : SuperConstraint { fun getRadiusImpl(component: UIComponent): Float - fun getRadius(component: UIComponent): Float { - val debugger = constraintDebugger - if (debugger != null) { - return debugger.evaluate(this, ConstraintType.RADIUS, component) - } - - if (recalculate) { - cachedValue = getRadiusImpl(component) - recalculate = false - } - - return cachedValue - } + fun getRadius(component: UIComponent): Float = + getCachedDebuggable(component, ConstraintType.RADIUS) { getRadiusImpl(it).roundToRealPixels() } } interface WidthConstraint : SuperConstraint { fun getWidthImpl(component: UIComponent): Float - fun getWidth(component: UIComponent): Float { - val debugger = constraintDebugger - if (debugger != null) { - return debugger.evaluate(this, ConstraintType.WIDTH, component) - } - - if (recalculate) { - cachedValue = getWidthImpl(component).roundToRealPixels() - recalculate = false - } - - return cachedValue - } + fun getWidth(component: UIComponent): Float = + getCachedDebuggable(component, ConstraintType.WIDTH) { getWidthImpl(it).roundToRealPixels() } } interface HeightConstraint : SuperConstraint { fun getHeightImpl(component: UIComponent): Float - fun getHeight(component: UIComponent): Float { - val debugger = constraintDebugger - if (debugger != null) { - return debugger.evaluate(this, ConstraintType.HEIGHT, component) - } - - if (recalculate) { - cachedValue = getHeightImpl(component).roundToRealPixels() - recalculate = false - } - - return cachedValue - } + fun getHeight(component: UIComponent): Float = + getCachedDebuggable(component, ConstraintType.HEIGHT) { getHeightImpl(it).roundToRealPixels() } fun getTextScale(component: UIComponent): Float { return getHeight(component) @@ -168,14 +116,32 @@ interface HeightConstraint : SuperConstraint { interface ColorConstraint : SuperConstraint { fun getColorImpl(component: UIComponent): Color - fun getColor(component: UIComponent): Color { - if (recalculate) { - cachedValue = getColorImpl(component) - recalculate = false - } + fun getColor(component: UIComponent): Color = + getCached(component) { getColorImpl(it) } +} - return cachedValue +interface MasterConstraint : PositionConstraint, SizeConstraint + +private inline fun SuperConstraint.getCachedDebuggable(component: UIComponent, type: ConstraintType, getImpl: (UIComponent) -> Float): Float { + val debugger = constraintDebugger + if (debugger != null) { + return debugger.evaluate(this, type, component) } + + return getCached(component, getImpl) } -interface MasterConstraint : PositionConstraint, SizeConstraint \ No newline at end of file +internal inline fun SuperConstraint.getCached(component: UIComponent, getImpl: (UIComponent) -> T): T { + if (recalculate) { + cachedValue = getImpl(component) + val window = Window.ofOrNull(component) + if (window != null) { + if (window.version >= ElementaVersion.v8) { + window.cachedConstraints.add(this) + } + recalculate = false + } + } + + return cachedValue +} diff --git a/src/main/kotlin/gg/essential/elementa/constraints/MaxConstraint.kt b/src/main/kotlin/gg/essential/elementa/constraints/MaxConstraint.kt index 51e70b0c..e326cc05 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/MaxConstraint.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/MaxConstraint.kt @@ -9,6 +9,8 @@ class MaxConstraint(val first: SuperConstraint, val second: SuperConstrai override var recalculate = true override var constrainTo: UIComponent? = null + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() first.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/constraints/MinConstraint.kt b/src/main/kotlin/gg/essential/elementa/constraints/MinConstraint.kt index 1b556005..b113a36a 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/MinConstraint.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/MinConstraint.kt @@ -9,6 +9,8 @@ class MinConstraint(val first: SuperConstraint, val second: SuperConstrai override var recalculate = true override var constrainTo: UIComponent? = null + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() first.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/constraints/ScaleConstraint.kt b/src/main/kotlin/gg/essential/elementa/constraints/ScaleConstraint.kt index c6f49718..43e61bd5 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/ScaleConstraint.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/ScaleConstraint.kt @@ -21,6 +21,8 @@ class ScaleConstraint(val constraint: SuperConstraint, value: State Unit) = apply { @@ -160,9 +161,17 @@ class AnimatingConstraints( completeAction = method::run } + internal var updateFunc: UpdateFunc? = null + + @Deprecated("See [ElementaVersion.V8].") override fun animationFrame() { + @Suppress("DEPRECATION") super.animationFrame() + updateCompletion(1) + } + + internal fun updateCompletion(dt: Int) { var anyLeftAnimating = false val x = x @@ -209,7 +218,7 @@ class AnimatingConstraints( if (extraDelayFrames > 0) { anyLeftAnimating = true - extraDelayFrames-- + extraDelayFrames -= dt } if (!anyLeftAnimating) { diff --git a/src/main/kotlin/gg/essential/elementa/constraints/animation/AnimationComponent.kt b/src/main/kotlin/gg/essential/elementa/constraints/animation/AnimationComponent.kt index 7bbdb8cb..0cf0736d 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/animation/AnimationComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/animation/AnimationComponent.kt @@ -1,6 +1,8 @@ package gg.essential.elementa.constraints.animation +import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent +import gg.essential.elementa.components.Window import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.debug.constraintDebugger import gg.essential.elementa.constraints.resolution.ConstraintVisitor @@ -18,6 +20,24 @@ sealed class AnimationComponent( var elapsedFrames = 0 var animationPaused = false + private var lastUpdateTime: Long = -1 + + internal fun update(component: UIComponent) { + val window = Window.ofOrNull(component) ?: return + if (window.version < ElementaVersion.v8) return // update handled by `animationFrame` + + val now = window.animationTimeMs + if (lastUpdateTime == -1L) lastUpdateTime = now + val dtMs = (now - lastUpdateTime).toInt() + lastUpdateTime = now + + if (!animationPaused) { + elapsedFrames = (elapsedFrames + dtMs).coerceAtMost(totalFrames + delayFrames) + } + } + + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -55,13 +75,16 @@ class XAnimationComponent( override var constrainTo: UIComponent? = null override fun getXPositionImpl(component: UIComponent): Float { + update(component) + val startX = oldConstraint.getXPosition(component) val finalX = newConstraint.getXPosition(component) return startX + ((finalX - startX) * getPercentComplete()) } - // TODO: This is gross, can probably be done in parent! + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -91,11 +114,16 @@ class YAnimationComponent( override var constrainTo: UIComponent? = null override fun getYPositionImpl(component: UIComponent): Float { + update(component) + val startX = oldConstraint.getYPosition(component) val finalX = newConstraint.getYPosition(component) return startX + ((finalX - startX) * getPercentComplete()) } + + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -125,12 +153,16 @@ class RadiusAnimationComponent( override var constrainTo: UIComponent? = null override fun getRadiusImpl(component: UIComponent): Float { + update(component) + val startX = oldConstraint.getRadius(component) val finalX = newConstraint.getRadius(component) return startX + ((finalX - startX) * getPercentComplete()) } + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -160,12 +192,16 @@ class WidthAnimationComponent( override var constrainTo: UIComponent? = null override fun getWidthImpl(component: UIComponent): Float { + update(component) + val startX = oldConstraint.getWidth(component) val finalX = newConstraint.getWidth(component) return startX + ((finalX - startX) * getPercentComplete()) } + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -195,6 +231,8 @@ class HeightAnimationComponent( override var constrainTo: UIComponent? = null override fun getHeightImpl(component: UIComponent): Float { + update(component) + val startX = oldConstraint.getHeight(component) val finalX = newConstraint.getHeight(component) @@ -216,6 +254,8 @@ class HeightAnimationComponent( return cachedValue } + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -245,6 +285,8 @@ class ColorAnimationComponent( override var constrainTo: UIComponent? = null override fun getColorImpl(component: UIComponent): Color { + update(component) + val startColor = oldConstraint.getColor(component) val endColor = newConstraint.getColor(component) val percentComplete = getPercentComplete() @@ -257,6 +299,8 @@ class ColorAnimationComponent( return Color(newR.roundToInt(), newG.roundToInt(), newB.roundToInt(), newA.roundToInt()) } + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() @@ -284,6 +328,8 @@ sealed class FieldAnimationComponent( abstract val field: KMutableProperty0<*> + @Deprecated("See [ElementaVersion.V8].") + @Suppress("DEPRECATION") override fun animationFrame() { super.animationFrame() diff --git a/src/main/kotlin/gg/essential/elementa/constraints/debug/NoopConstraintDebugger.kt b/src/main/kotlin/gg/essential/elementa/constraints/debug/NoopConstraintDebugger.kt index 56288c7b..185292da 100644 --- a/src/main/kotlin/gg/essential/elementa/constraints/debug/NoopConstraintDebugger.kt +++ b/src/main/kotlin/gg/essential/elementa/constraints/debug/NoopConstraintDebugger.kt @@ -3,14 +3,10 @@ package gg.essential.elementa.constraints.debug import gg.essential.elementa.UIComponent import gg.essential.elementa.constraints.ConstraintType import gg.essential.elementa.constraints.SuperConstraint +import gg.essential.elementa.constraints.getCached internal class NoopConstraintDebugger : ConstraintDebugger { override fun evaluate(constraint: SuperConstraint, type: ConstraintType, component: UIComponent): Float { - if (constraint.recalculate) { - constraint.cachedValue = invokeImpl(constraint, type, component) - constraint.recalculate = false - } - - return constraint.cachedValue + return constraint.getCached(component) { invokeImpl(constraint, type, component) } } } diff --git a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt index 05f2fbab..4a4f5223 100644 --- a/src/main/kotlin/gg/essential/elementa/effects/Effect.kt +++ b/src/main/kotlin/gg/essential/elementa/effects/Effect.kt @@ -61,6 +61,7 @@ abstract class Effect { /** * Called in the component's animationFrame function */ + @Deprecated("See [ElementaVersion.V8].") open fun animationFrame() {} /** diff --git a/src/main/kotlin/gg/essential/elementa/effects/RecursiveFadeEffect.kt b/src/main/kotlin/gg/essential/elementa/effects/RecursiveFadeEffect.kt index 58a15795..83b08a02 100644 --- a/src/main/kotlin/gg/essential/elementa/effects/RecursiveFadeEffect.kt +++ b/src/main/kotlin/gg/essential/elementa/effects/RecursiveFadeEffect.kt @@ -72,8 +72,10 @@ class RecursiveFadeEffect constructor( } } + @Deprecated("See [ElementaVersion.V8].") override fun animationFrame() { // We still want the original constraint's colour to recalculate while we are animating + @Suppress("DEPRECATION") originalConstraint.animationFrame() } diff --git a/src/main/kotlin/gg/essential/elementa/markdown/MarkdownComponent.kt b/src/main/kotlin/gg/essential/elementa/markdown/MarkdownComponent.kt index ce446998..4193a417 100644 --- a/src/main/kotlin/gg/essential/elementa/markdown/MarkdownComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/markdown/MarkdownComponent.kt @@ -1,10 +1,13 @@ package gg.essential.elementa.markdown +import gg.essential.elementa.ElementaVersion import gg.essential.elementa.UIComponent import gg.essential.elementa.components.MarkdownNode import gg.essential.elementa.components.TreeListComponent import gg.essential.elementa.components.TreeNode +import gg.essential.elementa.components.UpdateFunc import gg.essential.elementa.components.Window +import gg.essential.elementa.components.addUpdateFuncOnV8ReplacingAnimationFrame import gg.essential.elementa.constraints.HeightConstraint import gg.essential.elementa.dsl.pixels import gg.essential.elementa.events.UIEvent @@ -174,9 +177,16 @@ class MarkdownComponent( } ?: 0f } + init { addUpdateFuncOnV8ReplacingAnimationFrame { _, _ -> update() }} + @Deprecated("See [ElementaVersion.V8].") override fun animationFrame() { + @Suppress("DEPRECATION") super.animationFrame() + if (versionOrV0 >= ElementaVersion.v8) return // handled by UpdateFunc + update() + } + private fun update() { if (needsInitialLayout) { needsInitialLayout = false reparse() @@ -212,7 +222,7 @@ class MarkdownComponent( override fun draw(matrixStack: UMatrixStack) { if (needsInitialLayout) { - animationFrame() + update() } beforeDraw(matrixStack)