Skip to content
Merged
6 changes: 4 additions & 2 deletions api/Elementa.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1871,7 +1874,6 @@ public final class gg/essential/elementa/constraints/RainbowColorConstraint : gg
public fun <init> ()V
public fun <init> (IF)V
public synthetic fun <init> (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;
Expand Down
76 changes: 76 additions & 0 deletions src/main/kotlin/gg/essential/elementa/ElementaVersion.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,

;

/**
Expand Down Expand Up @@ -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
Expand Down
141 changes: 125 additions & 16 deletions src/main/kotlin/gg/essential/elementa/UIComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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)
Expand Down Expand Up @@ -139,6 +151,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) {
Expand Down Expand Up @@ -810,7 +825,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)
Expand All @@ -825,14 +871,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
Expand Down Expand Up @@ -1425,9 +1476,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(
Expand All @@ -1450,9 +1502,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(
Expand All @@ -1475,9 +1528,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(
Expand Down Expand Up @@ -1505,9 +1559,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(
Expand All @@ -1530,9 +1585,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(
Expand All @@ -1550,6 +1606,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")
Expand Down Expand Up @@ -1578,6 +1664,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
Expand Down Expand Up @@ -1607,7 +1694,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) {
Expand All @@ -1617,6 +1704,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) }
Expand All @@ -1640,6 +1747,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)

Expand All @@ -1656,6 +1764,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<*>) =
Expand Down
Loading