diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e6711515..18cfdc44 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- master
+ - unstable/import
pull_request:
jobs:
@@ -12,9 +13,9 @@ jobs:
env:
ORG_GRADLE_PROJECT_branch: ${{ github.head_ref || github.ref_name }}
steps:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v4
- - uses: actions/setup-java@v3
+ - uses: actions/setup-java@v4
with:
distribution: temurin
java-version: |
@@ -23,11 +24,11 @@ jobs:
17
# Can't use setup-java for this because https://github.com/actions/setup-java/issues/366
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
with:
path: ~/.gradle/wrapper
key: gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }}
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
with:
path: |
~/.gradle/caches
diff --git a/.gitignore b/.gitignore
index c9a75934..4965c82d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,3 @@ options.txt
usercache.json
usernamecache.json
*.txt
-
-versions/*/tmp.srg
-versions/*/api/
diff --git a/README.md b/README.md
index bb9927f1..436d2604 100644
--- a/README.md
+++ b/README.md
@@ -11,110 +11,46 @@ instead you simply have to describe _what_ you want.
## Dependency
-It's recommended that you include [Essential](link eventually) instead of adding it yourself.
-
-In your repository block, add:
-
-Groovy
-```groovy
-maven {
- url = "https://repo.essential.gg/repository/maven-public"
-}
-```
-Kotlin
-```kotlin
-maven(url = "https://repo.essential.gg/repository/maven-public")
-```
-
-To use the latest builds, use the following dependency:
-
-Forge
-
```kotlin
-implementation("gg.essential:elementa-$mcVersion-$mcPlatform:$buildNumber")
-```
-
-Fabric
-
-Groovy
-```groovy
-modImplementation(include("gg.essential:elementa-$mcVersion-$mcPlatform:$buildNumber"))
-```
-Kotlin
-```kotlin
-modImplementation(include("gg.essential:elementa-$mcVersion-$mcPlatform:$buildNumber")!!)
+repository {
+ // All versions of Elementa and UniversalCraft are published to Essential's public maven repository.
+ // (if you're still using Groovy build scripts, replace `()` with `{}`)
+ maven(url = "https://repo.essential.gg/repository/maven-public")
+}
+dependencies {
+ // Add Elementa dependency. For the latest $elementaVersion, see the badge below this code snippet.
+ implementation("gg.essential:elementa:$elementaVersion")
+
+ // Optionally, add some of the unstable Elementa features.
+ // Note that these MUST be relocated to your own package because future versions may contain breaking changes
+ // and therefore MUST NOT be simply included via Fabric's jar-in-jar mechanism.
+ implementation("gg.essential:elementa-unstable-layoutdsl:$elementaVersion")
+
+ // Elementa itself is independent of Minecraft versions and mod loaders, instead it depends on UniversalCraft which
+ // provides bindings to specific Minecraft versions.
+ // As such, you must include the UniversalCraft version for the Minecraft version + mod loader you're targeting.
+ // For a list of all available platforms, see https://github.com/EssentialGG/UniversalCraft
+ // For your convenience, the latest $ucVersion is also included in a badge below this code snippet.
+ // (Note: if you are not using Loom, replace `modImplementation` with `implementation` or your equivalent)
+ modImplementation("gg.essential:universalcraft-1.8.9-forge:$ucVersion")
+
+ // If you're using Fabric, you may use its jar-in-jar mechanism to bundle Elementa and UniversalCraft with your
+ // mod by additionally adding them to the `include` configuration like this (in place of the above):
+ implementation(include("gg.essential:elementa:$elementaVersion")!!)
+ modImplementation(include("gg.essential:universalcraft-1.8.9-forge:$ucVersion"))
+ // If you're using Forge, you must instead include them directly into your jar file and relocate them to your
+ // own package (this is important! otherwise you will be incompatible with other mods!)
+ // using e.g. https://gradleup.com/shadow/configuration/relocation/
+ // For an example, read the IMPORTANT section below.
+}
```
-
-
-### Build Reference
-Build Reference
-
-
-
- mcVersion |
- mcPlatform |
- buildNumber |
-
-
- 1.18.1 |
- fabric |
-
-
- |
-
-
- 1.18.1 |
- forge |
-
-
- |
-
-
- 1.17.1 |
- fabric |
-
-
- |
-
-
- 1.17.1 |
- forge |
-
-
- |
-
-
- 1.16.2 |
- forge |
-
-
- |
-
-
- 1.12.2 |
- forge |
-
-
- |
-
-
- 1.8.9 |
- forge |
- |
-
-
-
-
-
-
-If you were previously using v1.7.1 of Elementa and are now on the v2.0.0 builds, please refer to the
-[migration](docs/migration.md) document to know what has changed.
-
-To learn about all the new features in v2.0.0, please read the [what's new](docs/whatsnew.md) document.
+
+
IMPORTANT!
-If you are using forge, you must also relocate Elementa to avoid potential crashes with other mods. To do this, you will need to use the Shadow Gradle plugin.
+If you are using Forge, you must also relocate Elementa to avoid incompatibility with other mods.
+To do this, you may use the Shadow Gradle plugin:
Groovy Version
@@ -191,6 +127,11 @@ In your dependencies block, add:
implementation "club.sk1er:Elementa:1.7.1-$mcVersion"
```
+If you were previously using v1.7.1 of Elementa and are now on the v2.0.0 builds, please refer to the
+[migration](docs/migration.md) document to know what has changed.
+
+To learn about all the new features in v2.0.0, please read the [what's new](docs/whatsnew.md) document.
+
## Components
All the drawing in Elementa is done via UIComponents. There is a root component named `Window`
diff --git a/api/Elementa.api b/api/Elementa.api
index 00330dd0..6c4aeaa3 100644
--- a/api/Elementa.api
+++ b/api/Elementa.api
@@ -6,6 +6,7 @@ public final class gg/essential/elementa/ElementaVersion : java/lang/Enum {
public static final field V3 Lgg/essential/elementa/ElementaVersion;
public static final field V4 Lgg/essential/elementa/ElementaVersion;
public static final field V5 Lgg/essential/elementa/ElementaVersion;
+ public static final field V6 Lgg/essential/elementa/ElementaVersion;
public final fun enableFor (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object;
public static fun valueOf (Ljava/lang/String;)Lgg/essential/elementa/ElementaVersion;
public static fun values ()[Lgg/essential/elementa/ElementaVersion;
@@ -169,6 +170,11 @@ public final class gg/essential/elementa/UIComponent$Companion {
public final fun guiHint (FZ)F
}
+public final class gg/essential/elementa/UIComponent$sam$i$java_util_function_Predicate$0 : java/util/function/Predicate {
+ public fun (Lkotlin/jvm/functions/Function1;)V
+ public final synthetic fun test (Ljava/lang/Object;)Z
+}
+
public class gg/essential/elementa/UIConstraints : java/util/Observable {
public fun (Lgg/essential/elementa/UIComponent;)V
public final fun copy ()Lgg/essential/elementa/UIConstraints;
@@ -2560,8 +2566,16 @@ public final class gg/essential/elementa/dsl/UtilitiesKt {
public static final fun toConstraint (Ljava/awt/Color;)Lgg/essential/elementa/constraints/ConstantColorConstraint;
public static final fun width (CF)F
public static final fun width (Ljava/lang/String;FLgg/essential/elementa/font/FontProvider;)F
+ public static final synthetic fun width (Lnet/minecraft/class_2561;FLgg/essential/elementa/font/FontProvider;)F
+ public static final synthetic fun width (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;)F
+ public static final synthetic fun width (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;)F
+ public static final synthetic fun width (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;)F
public static synthetic fun width$default (CFILjava/lang/Object;)F
public static synthetic fun width$default (Ljava/lang/String;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F
+ public static synthetic fun width$default (Lnet/minecraft/class_2561;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F
+ public static synthetic fun width$default (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F
+ public static synthetic fun width$default (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F
+ public static synthetic fun width$default (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F
}
public abstract class gg/essential/elementa/effects/Effect {
diff --git a/build.gradle.kts b/build.gradle.kts
index 0ee9179a..2a3a109a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,18 +1,30 @@
import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute
import gg.essential.gradle.util.*
import gg.essential.gradle.util.RelocationTransform.Companion.registerRelocationAttribute
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
- kotlin("jvm") version "1.6.10"
+ kotlin("jvm") version "1.9.23"
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.8.0"
- id("org.jetbrains.dokka") version "1.6.10" apply false
+ id("org.jetbrains.dokka") version "1.9.20"
id("gg.essential.defaults")
+ id("gg.essential.defaults.maven-publish")
}
+group = "gg.essential"
+version = versionFromBuildIdAndBranch()
+
kotlin.jvmToolchain {
(this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8))
}
-tasks.compileKotlin.setJvmDefault("all-compatibility")
+
+tasks.withType {
+ setJvmDefault("all-compatibility")
+ kotlinOptions {
+ languageVersion = "1.6"
+ apiVersion = "1.6"
+ }
+}
val internal by configurations.creating {
val relocated = registerRelocationAttribute("internal-relocated") {
@@ -39,6 +51,7 @@ dependencies {
internal(libs.dom4j)
implementation(prebundle(internal))
+ compileOnly(project(":mc-stubs"))
// Depending on LWJGL3 instead of 2 so we can choose opengl bindings only
compileOnly("org.lwjgl:lwjgl-opengl:3.3.1")
// Depending on 1.8.9 for all of these because that's the oldest version we support
@@ -48,8 +61,25 @@ dependencies {
compileOnly("com.google.code.gson:gson:2.2.4")
}
+tasks.processResources {
+ inputs.property("project.version", project.version)
+ filesMatching("fabric.mod.json") {
+ expand("version" to project.version)
+ }
+}
+
+tasks.jar {
+ dependsOn(internal)
+ from({ internal.map { zipTree(it) } })
+}
+
apiValidation {
- ignoredProjects.add("platform")
- ignoredPackages.add("com.example")
+ ignoredProjects.addAll(subprojects.map { it.name })
nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal")
}
+
+java.withSourcesJar()
+
+publishing.publications.named("maven") {
+ artifactId = "elementa"
+}
diff --git a/versions/1.12.2-1.8.9.txt b/example/1.12.2-1.8.9.txt
similarity index 100%
rename from versions/1.12.2-1.8.9.txt
rename to example/1.12.2-1.8.9.txt
diff --git a/versions/1.15.2-1.12.2.txt b/example/1.15.2-1.12.2.txt
similarity index 100%
rename from versions/1.15.2-1.12.2.txt
rename to example/1.15.2-1.12.2.txt
diff --git a/versions/build.gradle.kts b/example/build.gradle.kts
similarity index 51%
rename from versions/build.gradle.kts
rename to example/build.gradle.kts
index a6f5ee04..b610de4e 100644
--- a/versions/build.gradle.kts
+++ b/example/build.gradle.kts
@@ -1,25 +1,14 @@
-import gg.essential.gradle.multiversion.excludeKotlinDefaultImpls
-import gg.essential.gradle.multiversion.mergePlatformSpecifics
import gg.essential.gradle.util.*
plugins {
kotlin("jvm")
- id("org.jetbrains.dokka")
id("gg.essential.multi-version")
id("gg.essential.defaults")
- id("gg.essential.defaults.maven-publish")
}
-group = "gg.essential"
-
java.withSourcesJar()
-tasks.compileKotlin.setJvmDefault(if (platform.mcVersion >= 11400) "all" else "all-compatibility")
loom.noServerRunConfigs()
-val common by configurations.creating
-configurations.compileClasspath { extendsFrom(common) }
-configurations.runtimeClasspath { extendsFrom(common) }
-
dependencies {
implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.kotlin.reflect)
@@ -29,7 +18,7 @@ dependencies {
exclude(group = "org.jetbrains.kotlin")
}
- common(project(":"))
+ implementation(project(":example:common"))
if (platform.isFabric) {
val fabricApiVersion = when(platform.mcVersion) {
@@ -57,37 +46,3 @@ dependencies {
}
}
}
-
-tasks.processResources {
- filesMatching(listOf("fabric.mod.json")) {
- filter { it.replace("\"com.example.examplemod.ExampleMod\"", "") }
- }
-}
-
-tasks.dokkaHtml {
- moduleName.set("Elementa $name")
-}
-
-tasks.jar {
- dependsOn(common)
- from({ common.map { zipTree(it) } })
- mergePlatformSpecifics()
-
- // We build the common module with legacy default impl for backwards compatibility, but we only need those for
- // 1.12.2 and older. Newer versions have never shipped with legacy default impl.
- if (platform.mcVersion >= 11400) {
- excludeKotlinDefaultImpls()
- }
-
- exclude("com/example/examplemod/**")
- exclude("META-INF/mods.toml")
- exclude("mcmod.info")
- exclude("kotlin/**")
- manifest {
- attributes(mapOf("FMLModType" to "LIBRARY"))
- }
-}
-
-tasks.named("sourcesJar") {
- from(project(":").sourceSets.main.map { it.allSource })
-}
diff --git a/example/common/build.gradle.kts b/example/common/build.gradle.kts
new file mode 100644
index 00000000..1f228961
--- /dev/null
+++ b/example/common/build.gradle.kts
@@ -0,0 +1,23 @@
+import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute
+
+plugins {
+ kotlin("jvm")
+ id("gg.essential.defaults")
+}
+repositories.mavenLocal()
+
+kotlin.jvmToolchain(8)
+
+val common = registerStripReferencesAttribute("common") {
+ excludes.add("net.minecraft")
+}
+
+dependencies {
+ api(libs.kotlin.stdlib.jdk8)
+
+ compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) {
+ attributes { attribute(common, true) }
+ }
+
+ api(project(":"))
+}
diff --git a/src/main/java/com/example/examplemod/ComponentsGui.kt b/example/common/src/main/kotlin/com/example/examplemod/ComponentsGui.kt
similarity index 100%
rename from src/main/java/com/example/examplemod/ComponentsGui.kt
rename to example/common/src/main/kotlin/com/example/examplemod/ComponentsGui.kt
diff --git a/src/main/java/com/example/examplemod/ExampleGui.kt b/example/common/src/main/kotlin/com/example/examplemod/ExampleGui.kt
similarity index 100%
rename from src/main/java/com/example/examplemod/ExampleGui.kt
rename to example/common/src/main/kotlin/com/example/examplemod/ExampleGui.kt
diff --git a/src/main/java/com/example/examplemod/ExampleServerList.kt b/example/common/src/main/kotlin/com/example/examplemod/ExampleServerList.kt
similarity index 100%
rename from src/main/java/com/example/examplemod/ExampleServerList.kt
rename to example/common/src/main/kotlin/com/example/examplemod/ExampleServerList.kt
diff --git a/src/main/java/com/example/examplemod/ExamplesGui.kt b/example/common/src/main/kotlin/com/example/examplemod/ExamplesGui.kt
similarity index 94%
rename from src/main/java/com/example/examplemod/ExamplesGui.kt
rename to example/common/src/main/kotlin/com/example/examplemod/ExamplesGui.kt
index cd39cb25..d1dfebfb 100644
--- a/src/main/java/com/example/examplemod/ExamplesGui.kt
+++ b/example/common/src/main/kotlin/com/example/examplemod/ExamplesGui.kt
@@ -6,7 +6,7 @@ import gg.essential.elementa.components.*
import gg.essential.elementa.constraints.*
import gg.essential.elementa.constraints.animation.Animations
import gg.essential.elementa.dsl.*
-import gg.essential.elementa.impl.Platform.Companion.platform
+import gg.essential.universal.UMinecraft
import gg.essential.universal.UScreen
import java.awt.Color
@@ -39,7 +39,7 @@ class ExamplesGui : WindowScreen(ElementaVersion.V2) {
}
}.onMouseClick {
try {
- platform.currentScreen = action()
+ UMinecraft.currentScreenObj = action()
} catch (e: Exception) {
e.printStackTrace()
}
diff --git a/src/main/java/com/example/examplemod/JavaTestGui.java b/example/common/src/main/kotlin/com/example/examplemod/JavaTestGui.java
similarity index 100%
rename from src/main/java/com/example/examplemod/JavaTestGui.java
rename to example/common/src/main/kotlin/com/example/examplemod/JavaTestGui.java
diff --git a/src/main/java/com/example/examplemod/KtTestGui.kt b/example/common/src/main/kotlin/com/example/examplemod/KtTestGui.kt
similarity index 100%
rename from src/main/java/com/example/examplemod/KtTestGui.kt
rename to example/common/src/main/kotlin/com/example/examplemod/KtTestGui.kt
diff --git a/versions/mainProject b/example/mainProject
similarity index 100%
rename from versions/mainProject
rename to example/mainProject
diff --git a/versions/root.gradle.kts b/example/root.gradle.kts
similarity index 65%
rename from versions/root.gradle.kts
rename to example/root.gradle.kts
index cf9a6646..dac88a53 100644
--- a/versions/root.gradle.kts
+++ b/example/root.gradle.kts
@@ -2,11 +2,8 @@ import gg.essential.gradle.util.*
plugins {
id("gg.essential.multi-version.root")
- id("gg.essential.multi-version.api-validation")
}
-version = versionFromBuildIdAndBranch()
-
preprocess {
val forge11801 = createNode("1.18.1-forge", 11801, "srg")
val fabric11801 = createNode("1.18.1-fabric", 11801, "yarn")
@@ -14,7 +11,6 @@ preprocess {
val fabric11701 = createNode("1.17.1-fabric", 11701, "yarn")
val fabric11602 = createNode("1.16.2-fabric", 11602, "yarn")
val forge11602 = createNode("1.16.2-forge", 11602, "srg")
- val forge11502 = createNode("1.15.2-forge", 11502, "srg")
val forge11202 = createNode("1.12.2-forge", 11202, "srg")
val forge10809 = createNode("1.8.9-forge", 10809, "srg")
@@ -23,13 +19,6 @@ preprocess {
forge11701.link(fabric11701)
fabric11701.link(fabric11602)
fabric11602.link(forge11602)
- forge11602.link(forge11502)
- forge11502.link(forge11202, file("1.15.2-1.12.2.txt"))
+ forge11602.link(forge11202, file("1.15.2-1.12.2.txt"))
forge11202.link(forge10809, file("1.12.2-1.8.9.txt"))
}
-
-apiValidation {
- ignoredProjects.addAll(subprojects.map { it.name })
- ignoredPackages.add("com.example")
- nonPublicMarkers.add("org.jetbrains.annotations.ApiStatus\$Internal")
-}
diff --git a/versions/src/main/java/com/example/examplemod/ExampleMod.java b/example/src/main/java/com/example/examplemod/ExampleMod.java
similarity index 100%
rename from versions/src/main/java/com/example/examplemod/ExampleMod.java
rename to example/src/main/java/com/example/examplemod/ExampleMod.java
diff --git a/versions/src/main/resources/META-INF/mods.toml b/example/src/main/resources/META-INF/mods.toml
similarity index 100%
rename from versions/src/main/resources/META-INF/mods.toml
rename to example/src/main/resources/META-INF/mods.toml
diff --git a/example/src/main/resources/fabric.mod.json b/example/src/main/resources/fabric.mod.json
new file mode 100644
index 00000000..11343b2f
--- /dev/null
+++ b/example/src/main/resources/fabric.mod.json
@@ -0,0 +1,10 @@
+{
+ "schemaVersion": 1,
+ "id": "examplemod",
+ "name": "Example Mod",
+ "version": "0",
+ "environment": "client",
+ "entrypoints": {
+ "client": ["com.example.examplemod.ExampleMod"]
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dc1504e2..ab068379 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,13 +1,15 @@
[versions]
kotlin = "1.5.10"
+kotlinx-coroutines = "1.5.2"
jetbrains-annotations = "23.0.0"
-universalcraft = "211"
+universalcraft = "349"
commonmark = "0.17.1"
dom4j = "2.1.1"
[libraries]
kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 15de9024..48c0a02c 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/mc-stubs/build.gradle.kts b/mc-stubs/build.gradle.kts
new file mode 100644
index 00000000..d03e0f6f
--- /dev/null
+++ b/mc-stubs/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ `java-library`
+}
+
+java.toolchain.languageVersion = JavaLanguageVersion.of(8)
diff --git a/mc-stubs/src/main/java/net/minecraft/class_2561.java b/mc-stubs/src/main/java/net/minecraft/class_2561.java
new file mode 100644
index 00000000..3ebbc08a
--- /dev/null
+++ b/mc-stubs/src/main/java/net/minecraft/class_2561.java
@@ -0,0 +1,4 @@
+package net.minecraft;
+
+public class class_2561 {
+}
diff --git a/mc-stubs/src/main/java/net/minecraft/network/chat/Component.java b/mc-stubs/src/main/java/net/minecraft/network/chat/Component.java
new file mode 100644
index 00000000..a9a957e3
--- /dev/null
+++ b/mc-stubs/src/main/java/net/minecraft/network/chat/Component.java
@@ -0,0 +1,4 @@
+package net.minecraft.network.chat;
+
+public class Component {
+}
diff --git a/mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java b/mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java
new file mode 100644
index 00000000..102b0582
--- /dev/null
+++ b/mc-stubs/src/main/java/net/minecraft/util/IChatComponent.java
@@ -0,0 +1,4 @@
+package net.minecraft.util;
+
+public class IChatComponent {
+}
diff --git a/mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java b/mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java
new file mode 100644
index 00000000..d792d8fa
--- /dev/null
+++ b/mc-stubs/src/main/java/net/minecraft/util/text/ITextComponent.java
@@ -0,0 +1,4 @@
+package net.minecraft.util.text;
+
+public class ITextComponent {
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e9984556..dcfc092c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,8 +8,9 @@ pluginManagement {
maven("https://repo.essential.gg/repository/maven-public")
}
plugins {
- val egtVersion = "0.3.0"
+ val egtVersion = "0.5.0"
id("gg.essential.defaults") version egtVersion
+ id("gg.essential.defaults.maven-publish") version egtVersion
id("gg.essential.multi-version.root") version egtVersion
id("gg.essential.multi-version.api-validation") version egtVersion
}
@@ -17,16 +18,21 @@ pluginManagement {
rootProject.name = "Elementa"
-include(":platform")
-project(":platform").apply {
- projectDir = file("versions/")
+
+include(":mc-stubs")
+
+include(":unstable:statev2")
+include(":unstable:layoutdsl")
+
+
+include(":example")
+project(":example").apply {
buildFileName = "root.gradle.kts"
}
-
+include(":example:common")
listOf(
"1.8.9-forge",
"1.12.2-forge",
- "1.15.2-forge",
"1.16.2-forge",
"1.16.2-fabric",
"1.17.1-fabric",
@@ -34,9 +40,8 @@ listOf(
"1.18.1-fabric",
"1.18.1-forge",
).forEach { version ->
- include(":platform:$version")
- project(":platform:$version").apply {
- projectDir = file("versions/$version")
+ include(":example:$version")
+ project(":example:$version").apply {
buildFileName = "../build.gradle.kts"
}
}
diff --git a/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt b/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt
index 2a0f72a7..3077ccef 100644
--- a/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt
+++ b/src/main/kotlin/gg/essential/elementa/ElementaVersion.kt
@@ -84,8 +84,14 @@ enum class ElementaVersion {
/**
* Change the behavior of scroll components to no longer require holding down shift when horizontal is the only possible scrolling direction.
*/
+ @Deprecated(DEPRECATION_MESSAGE)
V5,
+ /**
+ * [gg.essential.elementa.components.ScrollComponent] now has a minimum size for scrollbar grips.
+ */
+ V6,
+
;
/**
@@ -126,7 +132,9 @@ Be sure to read through all the changes between your current version and your ne
internal val v3 = V3
@Suppress("DEPRECATION")
internal val v4 = V4
+ @Suppress("DEPRECATION")
internal val v5 = V5
+ internal val v6 = V6
@PublishedApi
diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt
index b0290af9..9331f819 100644
--- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt
+++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt
@@ -752,6 +752,7 @@ abstract class UIComponent : Observable(), ReferenceHolder {
}
stoppedTimers.forEach { activeTimers.remove(it) }
+ stoppedTimers.clear()
}
open fun alwaysDrawChildren(): Boolean {
diff --git a/src/main/kotlin/gg/essential/elementa/WindowScreen.kt b/src/main/kotlin/gg/essential/elementa/WindowScreen.kt
index bfacc28e..e4488e5d 100644
--- a/src/main/kotlin/gg/essential/elementa/WindowScreen.kt
+++ b/src/main/kotlin/gg/essential/elementa/WindowScreen.kt
@@ -48,11 +48,11 @@ abstract class WindowScreen @JvmOverloads constructor(
afterInitialization()
}
- super.onDrawScreen(matrixStack, mouseX, mouseY, partialTicks)
-
if (drawDefaultBackground)
super.onDrawBackground(matrixStack, 0)
+ super.onDrawScreen(matrixStack, mouseX, mouseY, partialTicks)
+
// Now, we need to hook up Elementa to this GuiScreen. In practice, Elementa
// is not constrained to being used solely inside of a GuiScreen, all the programmer
// needs to do is call the [Window] events when appropriate, whenever that may be.
diff --git a/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt b/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt
index d8284482..112b38a9 100644
--- a/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt
+++ b/src/main/kotlin/gg/essential/elementa/components/SVGComponent.kt
@@ -2,7 +2,6 @@ package gg.essential.elementa.components
import gg.essential.elementa.UIComponent
import gg.essential.elementa.components.image.ImageProvider
-import gg.essential.elementa.impl.Platform.Companion.platform
import gg.essential.elementa.svg.SVGParser
import gg.essential.elementa.svg.data.SVG
import gg.essential.universal.UGraphics
@@ -33,7 +32,7 @@ class SVGComponent(private var svg: SVG) : UIComponent(), ImageProvider {
}
override fun drawImage(matrixStack: UMatrixStack, x: Double, y: Double, width: Double, height: Double, color: Color) {
- if (platform.mcVersion >= 11700) {
+ if (UGraphics.isCoreProfile()) {
// TODO heavily relies on legacy gl, at least need to use per-vertex color and convert lines/points to tris
return
}
diff --git a/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt b/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt
index de81528e..d67c829c 100644
--- a/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt
+++ b/src/main/kotlin/gg/essential/elementa/components/ScrollComponent.kt
@@ -32,7 +32,7 @@ class ScrollComponent constructor(
private val pixelsPerScroll: Float = 15f,
private val scrollAcceleration: Float = 1.0f,
customScissorBoundingBox: UIComponent? = null,
- private val passthroughScroll: Boolean = true
+ private val passthroughScroll: Boolean = true,
) : UIContainer() {
@JvmOverloads constructor(
emptyString: String = "",
@@ -44,7 +44,7 @@ class ScrollComponent constructor(
verticalScrollOpposite: Boolean = false,
pixelsPerScroll: Float = 15f,
scrollAcceleration: Float = 1.0f,
- customScissorBoundingBox: UIComponent? = null
+ customScissorBoundingBox: UIComponent? = null,
) : this (
emptyString,
innerPadding,
@@ -59,7 +59,7 @@ class ScrollComponent constructor(
verticalScrollOpposite,
pixelsPerScroll,
scrollAcceleration,
- customScissorBoundingBox
+ customScissorBoundingBox,
)
private val primaryScrollDirection
@@ -466,10 +466,17 @@ class ScrollComponent constructor(
}
}
+ val relativeConstraint = RelativeConstraint(clampedPercentage)
+ val desiredSizeConstraint = if (Window.of(this).version >= ElementaVersion.v6) {
+ ScrollBarGripMinSizeConstraint(relativeConstraint)
+ } else {
+ relativeConstraint
+ }
+
if (isHorizontal) {
- component.setWidth(RelativeConstraint(clampedPercentage))
+ component.setWidth(desiredSizeConstraint)
} else {
- component.setHeight(RelativeConstraint(clampedPercentage))
+ component.setHeight(desiredSizeConstraint)
}
component.animate {
@@ -800,6 +807,48 @@ class ScrollComponent constructor(
}
+ /**
+ * Constraints the scrollbar grip's size to be a certain minimum size, or the [desiredSize].
+ * This is the default constraint for horizontal scrollbar grips if [ElementaVersion.V6] is used.
+ *
+ * @param desiredSize The intended size for the scrollbar grip.
+ */
+ private class ScrollBarGripMinSizeConstraint(
+ private val desiredSize: SizeConstraint
+ ) : SizeConstraint {
+ override var cachedValue: Float = 0f
+ override var recalculate: Boolean = true
+ override var constrainTo: UIComponent? = null
+
+ override fun animationFrame() {
+ super.animationFrame()
+ desiredSize.animationFrame()
+ }
+
+ override fun getWidthImpl(component: UIComponent): Float {
+ val parent = component.parent
+ val minimumWidthPercentage = if (parent.getWidth() < 200) { 0.15f } else { 0.10f }
+ val minimumWidth = parent.getWidth() * minimumWidthPercentage
+
+ return desiredSize.getWidth(component).coerceAtLeast(minimumWidth)
+ }
+
+ override fun getHeightImpl(component: UIComponent): Float {
+ val parent = component.parent
+ val minimumHeightPercentage = if (parent.getHeight() < 200) { 0.15f } else { 0.10f }
+ val minimumHeight = parent.getHeight() * minimumHeightPercentage
+
+ return desiredSize.getHeight(component).coerceAtLeast(minimumHeight)
+ }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {
+ }
+
+ override fun getRadiusImpl(component: UIComponent): Float {
+ throw IllegalStateException("`ScrollBarGripMinSizeConstraint` does not support `getRadiusImpl`.")
+ }
+ }
+
enum class Direction {
Vertical,
Horizontal,
diff --git a/src/main/kotlin/gg/essential/elementa/components/UIShape.kt b/src/main/kotlin/gg/essential/elementa/components/UIShape.kt
index 63ec770d..f71a69cc 100644
--- a/src/main/kotlin/gg/essential/elementa/components/UIShape.kt
+++ b/src/main/kotlin/gg/essential/elementa/components/UIShape.kt
@@ -2,7 +2,6 @@ package gg.essential.elementa.components
import gg.essential.elementa.UIComponent
import gg.essential.elementa.dsl.toConstraint
-import gg.essential.elementa.impl.Platform.Companion.platform
import gg.essential.universal.UGraphics
import gg.essential.universal.UMatrixStack
import org.lwjgl.opengl.GL11
@@ -52,7 +51,7 @@ open class UIShape @JvmOverloads constructor(color: Color = Color.WHITE) : UICom
val worldRenderer = UGraphics.getFromTessellator()
UGraphics.tryBlendFuncSeparate(770, 771, 1, 0)
- if (platform.mcVersion >= 11700) {
+ if (UGraphics.isCoreProfile()) {
worldRenderer.beginWithDefaultShader(UGraphics.DrawMode.TRIANGLE_FAN, UGraphics.CommonVertexFormats.POSITION_COLOR)
} else {
worldRenderer.begin(drawMode, UGraphics.CommonVertexFormats.POSITION_COLOR)
diff --git a/src/main/kotlin/gg/essential/elementa/components/UIText.kt b/src/main/kotlin/gg/essential/elementa/components/UIText.kt
index 9a3a43fd..7030c4b2 100644
--- a/src/main/kotlin/gg/essential/elementa/components/UIText.kt
+++ b/src/main/kotlin/gg/essential/elementa/components/UIText.kt
@@ -96,13 +96,18 @@ constructor(
}
override fun draw(matrixStack: UMatrixStack) {
- val text = textState.get()
- if (text.isEmpty())
+ val textWidth = textWidthState.get()
+
+ // If you're wondering why we check if the text's width is 0 instead of if the string is empty:
+ // It's better to check the width derived from the font provider, as the string may just be full of characters
+ // that can't be rendered (as they aren't supported by current font).
+ // This check prevents issues from occurring later, e.g. when calculating the scale of the text.
+ if (textWidth == 0f)
return
beforeDrawCompat(matrixStack)
- val scale = getWidth() / textWidthState.get()
+ val scale = getWidth() / textWidth
val x = getLeft()
val y = getTop() + (if (verticallyCenteredState.get()) fontProviderState.get().getBelowLineHeight() * scale else 0f)
val color = getColor()
diff --git a/src/main/kotlin/gg/essential/elementa/components/Window.kt b/src/main/kotlin/gg/essential/elementa/components/Window.kt
index 948c45ee..0dfc3e87 100644
--- a/src/main/kotlin/gg/essential/elementa/components/Window.kt
+++ b/src/main/kotlin/gg/essential/elementa/components/Window.kt
@@ -7,7 +7,6 @@ import gg.essential.elementa.constraints.resolution.ConstraintResolver
import gg.essential.elementa.constraints.resolution.ConstraintResolverV2
import gg.essential.elementa.effects.ScissorEffect
import gg.essential.elementa.font.FontRenderer
-import gg.essential.elementa.impl.Platform.Companion.platform
import gg.essential.elementa.utils.elementaDev
import gg.essential.elementa.utils.requireMainThread
import gg.essential.universal.*
@@ -47,6 +46,8 @@ class Window @JvmOverloads constructor(
}
override fun afterInitialization() {
+ super.afterInitialization()
+
enqueueRenderOperation {
FontRenderer.initShaders()
UICircle.initShaders()
@@ -110,7 +111,7 @@ class Window @JvmOverloads constructor(
} catch (e: Throwable) {
hasErrored = true
- val guiName = platform.currentScreen?.javaClass?.simpleName ?: ""
+ val guiName = UMinecraft.currentScreenObj?.javaClass?.simpleName ?: ""
when (e) {
is StackOverflowError -> {
println("Elementa: Cyclic constraint structure detected!")
@@ -128,7 +129,7 @@ class Window @JvmOverloads constructor(
ScissorEffect.currentScissorState = null
GL11.glDisable(GL11.GL_SCISSOR_TEST)
- platform.currentScreen = when {
+ UMinecraft.currentScreenObj = when {
e is StackOverflowError && elementaDev -> {
val cyclicNodes = when (System.getProperty("elementa.dev.cycle_resolver", "2")) {
"2" -> ConstraintResolverV2(this).getCyclicNodes()
diff --git a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt
index 4ae4ddc0..7aff6519 100644
--- a/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt
+++ b/src/main/kotlin/gg/essential/elementa/components/input/AbstractTextInput.kt
@@ -6,7 +6,6 @@ import gg.essential.elementa.constraints.CenterConstraint
import gg.essential.elementa.constraints.animation.Animations
import gg.essential.elementa.dsl.*
import gg.essential.elementa.effects.ScissorEffect
-import gg.essential.elementa.impl.Platform.Companion.platform
import gg.essential.elementa.utils.getStringSplitToWidth
import gg.essential.universal.UDesktop
import gg.essential.universal.UKeyboard
@@ -104,7 +103,7 @@ abstract class AbstractTextInput(
val operationToRedo = redoStack.pop()
operationToRedo.redo()
undoStack.push(operationToRedo)
- } else if (platform.isAllowedInChat(typedChar)) { // Most of the ASCII characters
+ } else if (isAllowedCharacter(typedChar)) { // Most of the ASCII characters
commitTextAddition(typedChar.toString())
} else if (keyCode == UKeyboard.KEY_LEFT) {
val holdingShift = UKeyboard.isShiftKeyDown()
@@ -978,4 +977,11 @@ abstract class AbstractTextInput(
removeTextOperation.undo()
}
}
+
+ private companion object {
+ // Mirroring ChatAllowedCharacters.isAllowedCharacter
+ private fun isAllowedCharacter(chr: Char): Boolean {
+ return chr.code != 167 && chr >= ' ' && chr.code != 127
+ }
+ }
}
diff --git a/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt b/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt
index 7ab8563b..43536fdb 100644
--- a/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt
+++ b/src/main/kotlin/gg/essential/elementa/dsl/utilities.kt
@@ -4,6 +4,7 @@ import gg.essential.elementa.constraints.*
import gg.essential.elementa.font.DefaultFonts
import gg.essential.elementa.font.FontProvider
import gg.essential.universal.UGraphics
+import gg.essential.universal.wrappers.message.UTextComponent
import java.awt.Color
fun Char.width(textScale: Float = 1f) = UGraphics.getCharWidth(this) * textScale
@@ -44,3 +45,20 @@ operator fun Color.component1() = red
operator fun Color.component2() = green
operator fun Color.component3() = blue
operator fun Color.component4() = alpha
+
+// Fabric
+@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN)
+fun net.minecraft.class_2561.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) =
+ UTextComponent.from(this)!!.text.width(textScale, fontProvider)
+// Forge 1.8
+@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN)
+fun net.minecraft.util.IChatComponent.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) =
+ UTextComponent.from(this)!!.text.width(textScale, fontProvider)
+// Forge 1.12-1.16
+@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN)
+fun net.minecraft.util.text.ITextComponent.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) =
+ UTextComponent.from(this)!!.text.width(textScale, fontProvider)
+// Forge 1.17+
+@Deprecated("Direct Minecraft dependency", level = DeprecationLevel.HIDDEN)
+fun net.minecraft.network.chat.Component.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) =
+ UTextComponent.from(this)!!.text.width(textScale, fontProvider)
diff --git a/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt b/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt
index ac74d6bc..fea84db8 100644
--- a/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt
+++ b/src/main/kotlin/gg/essential/elementa/effects/StencilEffect.kt
@@ -1,6 +1,6 @@
package gg.essential.elementa.effects
-import gg.essential.elementa.impl.Platform.Companion.platform
+import gg.essential.universal.UGraphics
import gg.essential.universal.UMatrixStack
import org.lwjgl.opengl.GL11.*
@@ -9,6 +9,7 @@ import org.lwjgl.opengl.GL11.*
*
* In order to use, you must call [enableStencil] in mod initialization.
*/
+@Deprecated("Does not work on 1.14+")
class StencilEffect : Effect() {
override fun beforeDraw(matrixStack: UMatrixStack) {
glEnable(GL_STENCIL_TEST)
@@ -32,8 +33,9 @@ class StencilEffect : Effect() {
/**
* Must be called in mod initialization to use [StencilEffect]
*/
- @JvmStatic fun enableStencil() { //TODO wait for 1.15 to impl
- platform.enableStencil()
+ @JvmStatic fun enableStencil() {
+ @Suppress("DEPRECATION")
+ UGraphics.enableStencil()
}
}
}
diff --git a/src/main/kotlin/gg/essential/elementa/impl/Platform.kt b/src/main/kotlin/gg/essential/elementa/impl/Platform.kt
deleted file mode 100644
index 9eec5117..00000000
--- a/src/main/kotlin/gg/essential/elementa/impl/Platform.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package gg.essential.elementa.impl
-
-import org.jetbrains.annotations.ApiStatus
-import java.util.*
-
-@ApiStatus.Internal
-interface Platform {
- val mcVersion: Int
-
- var currentScreen: Any?
-
- fun isAllowedInChat(char: Char): Boolean
-
- fun enableStencil()
-
- fun isCallingFromMinecraftThread(): Boolean
-
- @ApiStatus.Internal
- companion object {
- internal val platform: Platform =
- ServiceLoader.load(Platform::class.java, Platform::class.java.classLoader).iterator().next()
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt b/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt
index 5142e514..0b115781 100644
--- a/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt
+++ b/src/main/kotlin/gg/essential/elementa/utils/invalidUsage.kt
@@ -1,6 +1,6 @@
package gg.essential.elementa.utils
-import gg.essential.elementa.impl.Platform.Companion.platform
+import gg.essential.universal.UMinecraft
internal enum class InvalidUsageBehavior {
IGNORE,
@@ -39,5 +39,5 @@ internal fun requireState(state: Boolean, message: String) {
/** Ensure a method can only be called from the main thread. Lack of this check does **not** imply thread-safety. */
internal fun requireMainThread(message: String = "This method is not thread-safe and must be called from the main thread. " +
"Consider the thread-safety of the calling code and use Window.enqueueRenderOperation if applicable.") {
- requireState(platform.isCallingFromMinecraftThread(), message)
+ requireState(UMinecraft.isCallingFromMinecraftThread(), message)
}
diff --git a/versions/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
similarity index 71%
rename from versions/src/main/resources/fabric.mod.json
rename to src/main/resources/fabric.mod.json
index d00bdd1d..4f3cd859 100644
--- a/versions/src/main/resources/fabric.mod.json
+++ b/src/main/resources/fabric.mod.json
@@ -4,9 +4,6 @@
"name": "Elementa",
"version": "${version}",
"environment": "client",
- "entrypoints": {
- "client": ["com.example.examplemod.ExampleMod"]
- },
"custom": {
"modmenu": {
"badges": [ "library" ]
diff --git a/unstable/layoutdsl/build.gradle.kts b/unstable/layoutdsl/build.gradle.kts
new file mode 100644
index 00000000..2b44504c
--- /dev/null
+++ b/unstable/layoutdsl/build.gradle.kts
@@ -0,0 +1,41 @@
+import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute
+import gg.essential.gradle.util.setJvmDefault
+import gg.essential.gradle.util.versionFromBuildIdAndBranch
+
+plugins {
+ kotlin("jvm")
+ id("gg.essential.defaults")
+ id("gg.essential.defaults.maven-publish")
+}
+
+version = versionFromBuildIdAndBranch()
+group = "gg.essential"
+
+dependencies {
+ compileOnly(project(":"))
+ api(project(":unstable:statev2"))
+
+ val common = registerStripReferencesAttribute("common") {
+ excludes.add("net.minecraft")
+ }
+ compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) {
+ attributes { attribute(common, true) }
+ }
+ // Depending on LWJGL3 instead of 2 so we can choose opengl bindings only
+ compileOnly("org.lwjgl:lwjgl-opengl:3.3.1")
+}
+tasks.compileKotlin.setJvmDefault("all")
+
+kotlin.jvmToolchain {
+ (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8))
+}
+
+java.withSourcesJar()
+
+publishing {
+ publications {
+ named("maven") {
+ artifactId = "elementa-unstable-${project.name}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt
new file mode 100644
index 00000000..bf83452e
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/HollowUIContainer.kt
@@ -0,0 +1,16 @@
+package gg.essential.elementa.common
+
+import gg.essential.elementa.components.UIContainer
+
+/**
+ * A UIContainer that does not return true for [isPointInside] unless
+ * any of the child are hovered
+ */
+open class HollowUIContainer : UIContainer() {
+
+ override fun isPointInside(x: Float, y: Float): Boolean {
+ return children.any {
+ it.isPointInside(x, y)
+ }
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt
new file mode 100644
index 00000000..7a931c7c
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/ListState.kt
@@ -0,0 +1,189 @@
+package gg.essential.elementa.common
+
+import gg.essential.elementa.state.BasicState
+import gg.essential.elementa.state.State
+import gg.essential.elementa.state.v2.collections.TrackedList
+
+typealias AddListener = (index: Int, element: T) -> Unit
+typealias SetListener = (index: Int, element: T, oldElement: T) -> Unit
+typealias RemoveListener = AddListener
+typealias ClearListener = (list: List) -> Unit
+
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("mutableListState(state)", "gg.essential.elementa.state.v2.ListKt.mutableListState"))
+class ListState(initialList: MutableList = mutableListOf()) : BasicState>(initialList) {
+ private val addListeners = Listeners>()
+ private val setListeners = Listeners>()
+ private val removeListeners = Listeners>()
+ private val clearListeners = Listeners>()
+
+ fun onAdd(action: AddListener) = apply {
+ addListeners.add(action)
+ }
+
+ fun onSet(action: SetListener) = apply {
+ setListeners.add(action)
+ }
+
+ fun onRemove(action: RemoveListener) = apply {
+ removeListeners.add(action)
+ }
+
+ fun onClear(action: ClearListener) = apply {
+ clearListeners.add(action)
+ }
+
+ fun add(element: T) = apply {
+ add(get().size, element)
+ }
+
+ fun add(index: Int, element: T) = apply {
+ get().add(index, element)
+ addListeners.forEach { it(index, element) }
+ }
+
+ fun set(index: Int, element: T) = apply {
+ get().also { list ->
+ val oldValue = list[index]
+ if (element == oldValue)
+ return@also
+ list[index] = element
+ setListeners.forEach { it(index, element, oldValue) }
+ }
+ }
+
+ fun remove(element: T) = apply {
+ removeAt(get().indexOf(element))
+ }
+
+ fun removeAt(index: Int) = apply {
+ get().also { list ->
+ val element = list.removeAt(index)
+ removeListeners.forEach { it(index, element) }
+ }
+ }
+
+ fun clear() = apply {
+ val list = get()
+ val values = list.toList()
+ list.clear()
+ clearListeners.forEach { it(values) }
+ }
+
+ fun onElementAddedOrPresent(action: (element: T) -> Unit) = apply {
+ onElementAdded(action)
+ get().forEach(action)
+ }
+
+ fun onElementAdded(action: (element: T) -> Unit) = apply {
+ onAdd { _, element ->
+ action(element)
+ }
+
+ onSet { _, element, _ ->
+ action(element)
+ }
+ }
+
+ fun onElementRemoved(action: (element: T) -> Unit) = apply {
+ onSet { _, _, oldElement ->
+ action(oldElement)
+ }
+
+ onRemove { _, element ->
+ action(element)
+ }
+
+ onClear {
+ it.forEach(action)
+ }
+ }
+
+ fun onElementAddedOrRemoved(action: (element: T) -> Unit) = apply {
+ onElementAdded(action)
+ onElementRemoved(action)
+ }
+
+ fun onElementAddedOrRemovedOrPresent(action: (element: T) -> Unit) = apply {
+ onElementAddedOrRemoved(action)
+ get().forEach(action)
+ }
+
+ operator fun contains(element: T) = element in get()
+
+ fun reduce(mapper: (List) -> U) = MappedListState(this, mapper)
+
+ companion object {
+ fun from(state: State>): ListState {
+ val listState = ListState()
+ state.onSetValueAndNow { newList ->
+ for (change in TrackedList.Change.estimate(listState.get(), newList)) {
+ when (change) {
+ is TrackedList.Clear -> listState.clear()
+ is TrackedList.Add -> listState.add(change.element.index, change.element.value)
+ is TrackedList.Remove -> listState.removeAt(change.element.index)
+ }
+ }
+ }
+ return listState
+ }
+ }
+}
+
+fun ListState.mapList(mapper: (List) -> List): ListState = ListState.from(reduce(mapper))
+
+// TODO: all of these are quite inefficient, might make sense to implement some as efficient primitives instead
+
+fun ListState.filter(filter: (T) -> Boolean) = mapList { it.filter(filter) }
+
+fun ListState.map(mapper: (T) -> U) = mapList { it.map(mapper) }
+
+fun ListState.zip(otherState: State, transform: (T, U) -> V) =
+ ListState.from(reduce { it.toList() }.zip(otherState).map { (list, other) -> list.map { transform(it, other) } })
+
+fun ListState.zip(otherList: ListState, transform: (T, U) -> V) =
+ ListState.from(reduce { it.toList() }.zip(otherList.reduce { it.toList() }).map { (a, b) -> a.zip(b, transform) })
+
+fun ListState.mapNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) }
+
+fun ListState.filterNotNull() = mapList { it.filterNotNull() }
+
+inline fun ListState<*>.filterIsInstance() = mapList { it.filterIsInstance() }
+
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("state.map", "gg.essential.elementa.state.v2.combinators.StateKt.map"))
+class MappedListState(state: ListState, mapper: (List) -> U) : BasicState(mapper(state.get())) {
+ init {
+ state.onAdd { _, _ ->
+ set(mapper(state.get()))
+ }
+
+ state.onSet { _, _, _ ->
+ set(mapper(state.get()))
+ }
+
+ state.onRemove { _, _ ->
+ set(mapper(state.get()))
+ }
+
+ state.onClear {
+ set(mapper(emptyList()))
+ }
+ }
+}
+
+/** A mutable list of listeners that is safe to extend while being iterated. */
+private class Listeners {
+ private val active = mutableListOf()
+ private val new = mutableListOf()
+
+ fun add(listener: T) {
+ new.add(listener)
+ }
+
+ fun forEach(caller: (T) -> Unit) {
+ if (new.isNotEmpty()) {
+ active.addAll(new)
+ new.clear()
+ }
+ active.forEach(caller)
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt
new file mode 100644
index 00000000..f2ada434
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/Spacer.kt
@@ -0,0 +1,27 @@
+package gg.essential.elementa.common
+
+import gg.essential.elementa.constraints.HeightConstraint
+import gg.essential.elementa.constraints.SiblingConstraint
+import gg.essential.elementa.constraints.WidthConstraint
+import gg.essential.elementa.dsl.constrain
+import gg.essential.elementa.dsl.pixels
+
+/**
+ * A simple UIContainer where you can specify [width], [height], or both.
+ *
+ * If only [width] is specified, X-axis will be constrained to [SiblingConstraint].
+ *
+ * If only [height] is specified, Y-axis will be constrained to [SiblingConstraint].
+ */
+class Spacer(width: WidthConstraint = 0.pixels, height: HeightConstraint = 0.pixels) : HollowUIContainer() {
+ constructor(width: Float, _desc: Int = 0) : this(width = width.pixels) { setX(SiblingConstraint()) }
+ constructor(height: Float, _desc: Short = 0) : this(height = height.pixels) { setY(SiblingConstraint()) }
+ constructor(width: Float, height: Float) : this(width = width.pixels, height = height.pixels)
+
+ init {
+ constrain {
+ this.width = width
+ this.height = height
+ }
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt
new file mode 100644
index 00000000..190a1e01
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/AlternateConstraint.kt
@@ -0,0 +1,66 @@
+package gg.essential.elementa.common.constraints
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.ConstraintType
+import gg.essential.elementa.constraints.SizeConstraint
+import gg.essential.elementa.constraints.resolution.ConstraintVisitor
+
+/**
+ * Constraint which tries to evaluate the given constraint but falls back to another constraint if the first constraint
+ * results in a circular constraint chain.
+ *
+ * You probably shouldn't use this. With great power comes great responsibility.
+ * Be sure to fully understand how this works and interacts with other constraints before using, otherwise you may see
+ * undefined behavior such as unstable results, stack overflow, etc. if any of the involved constraints are not pure
+ * or more generally not safe to evaluate recursively (this one for example isn't, so don't ever use multiple).
+ */
+class AlternateConstraint(
+ val primary: SizeConstraint,
+ val fallback: SizeConstraint,
+) : SizeConstraint {
+ override var recalculate: Boolean = true
+ override var cachedValue: Float = 0f
+ override var constrainTo: UIComponent?
+ get() = null
+ set(value) = throw UnsupportedOperationException()
+
+ private var tryingPrimary = false
+ private var primaryWasRecursive = false
+
+ override fun animationFrame() {
+ primary.animationFrame()
+ fallback.animationFrame()
+
+ super.animationFrame()
+ }
+
+ private inline fun eval(eval: (SizeConstraint) -> Float): Float {
+ if (!tryingPrimary) {
+ tryingPrimary = true
+ try {
+ primaryWasRecursive = false
+ val value = eval(primary)
+ if (!primaryWasRecursive) {
+ return value
+ }
+ } finally {
+ tryingPrimary = false
+ }
+ } else {
+ primaryWasRecursive = true
+ }
+ return eval(fallback)
+ }
+
+
+ override fun getWidthImpl(component: UIComponent): Float =
+ eval { it.getWidth(component) }
+
+ override fun getHeightImpl(component: UIComponent): Float =
+ eval { it.getHeight(component) }
+
+ override fun getRadiusImpl(component: UIComponent): Float =
+ eval { it.getRadius(component) }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {}
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt
new file mode 100644
index 00000000..64d72bd4
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/CenterPixelConstraint.kt
@@ -0,0 +1,42 @@
+package gg.essential.elementa.common.constraints
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.ConstraintType
+import gg.essential.elementa.constraints.PositionConstraint
+import gg.essential.elementa.constraints.resolution.ConstraintVisitor
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/** Centers the component to whole pixels, rounding down unless [roundUp] is true */
+class CenterPixelConstraint(private val roundUp: Boolean = false) : PositionConstraint {
+
+ override var cachedValue = 0f
+ override var recalculate = true
+ override var constrainTo: UIComponent? = null
+
+ override fun getXPositionImpl(component: UIComponent): Float {
+ val parent = constrainTo ?: component.parent
+
+ val center = if (component.isPositionCenter()) {
+ parent.getWidth() / 2
+ } else {
+ parent.getWidth() / 2 - component.getWidth() / 2
+ }
+
+ return parent.getLeft() + if (roundUp) ceil(center) else floor(center)
+ }
+
+ override fun getYPositionImpl(component: UIComponent): Float {
+ val parent = constrainTo ?: component.parent
+
+ val center = if (component.isPositionCenter()) {
+ parent.getHeight() / 2
+ } else {
+ parent.getHeight() / 2 - component.getHeight() / 2
+ }
+
+ return parent.getTop() + if (roundUp) ceil(center) else floor(center)
+ }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {}
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt
new file mode 100644
index 00000000..8bea02c6
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/FillConstraintIncludingPadding.kt
@@ -0,0 +1,101 @@
+package gg.essential.elementa.common.constraints
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.ConstraintType
+import gg.essential.elementa.constraints.PaddingConstraint
+import gg.essential.elementa.constraints.SizeConstraint
+import gg.essential.elementa.constraints.resolution.ConstraintVisitor
+
+/**
+ * @see FillConstraint but includes padding in width and height calculations to correctly position the component
+ */
+class FillConstraintIncludingPadding @JvmOverloads constructor(private val useSiblings: Boolean = true) : SizeConstraint {
+ override var cachedValue = 0f
+ override var recalculate = true
+ override var constrainTo: UIComponent? = null
+
+ override fun getWidthImpl(component: UIComponent): Float {
+ val target = constrainTo ?: component.parent
+
+ return if (useSiblings) {
+ target.getWidth() - target.children.sumOf {
+ val width = if (it == component) 0 else it.getWidth()
+ width.toDouble() + ((it.constraints.x as? PaddingConstraint)?.getHorizontalPadding(it) ?: 0f).toDouble()
+ }.toFloat()
+ } else target.getRight() - component.getLeft() + ((target.constraints.x as? PaddingConstraint)?.getHorizontalPadding(target) ?: 0f)
+ }
+
+ override fun getHeightImpl(component: UIComponent): Float {
+ val target = constrainTo ?: component.parent
+
+ return if (useSiblings) {
+ target.getHeight() - target.children.sumOf {
+ val height = if (it == component) 0 else it.getHeight()
+ height.toDouble() + ((it.constraints.y as? PaddingConstraint)?.getVerticalPadding(it) ?: 0f).toDouble()
+ }.toFloat()
+ } else target.getBottom() - component.getTop() + ((target.constraints.y as? PaddingConstraint)?.getVerticalPadding(target) ?: 0f)
+ }
+
+ override fun getRadiusImpl(component: UIComponent): Float {
+ val target = constrainTo ?: component.parent
+
+ return if (useSiblings) {
+ target.getRadius() - target.children.filter { it != component }.sumOf {
+ it.getRadius().toDouble()
+ }.toFloat()
+ } else (target.getRadius() - component.getLeft()) / 2f
+ }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {
+ when (type) {
+ ConstraintType.WIDTH -> {
+ visitor.visitParent(ConstraintType.WIDTH)
+
+ if (useSiblings) {
+ val indexInParent = visitor.component.let { it.parent.children.indexOf(it) }
+ val numParentChildren = visitor.component.parent.children.size
+
+ for (i in 0 until numParentChildren) {
+ if (indexInParent != i)
+ visitor.visitSibling(ConstraintType.WIDTH, i)
+ }
+ } else {
+ visitor.visitParent(ConstraintType.X)
+ visitor.visitSelf(ConstraintType.X)
+ }
+ }
+ ConstraintType.HEIGHT -> {
+ visitor.visitParent(ConstraintType.HEIGHT)
+
+ if (useSiblings) {
+ val indexInParent = visitor.component.let { it.parent.children.indexOf(it) }
+ val numParentChildren = visitor.component.parent.children.size
+
+ for (i in 0 until numParentChildren) {
+ if (indexInParent != i)
+ visitor.visitSibling(ConstraintType.HEIGHT, i)
+ }
+ } else {
+ visitor.visitParent(ConstraintType.Y)
+ visitor.visitSelf(ConstraintType.Y)
+ }
+ }
+ ConstraintType.RADIUS -> {
+ visitor.visitParent(ConstraintType.RADIUS)
+
+ if (useSiblings) {
+ val indexInParent = visitor.component.let { it.parent.children.indexOf(it) }
+ val numParentChildren = visitor.component.parent.children.size
+
+ for (i in 0 until numParentChildren) {
+ if (indexInParent != i)
+ visitor.visitSibling(ConstraintType.RADIUS, i)
+ }
+ } else {
+ visitor.visitSelf(ConstraintType.X)
+ }
+ }
+ else -> throw IllegalArgumentException(type.prettyName)
+ }
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt
new file mode 100644
index 00000000..d72eecae
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/constraints/SpacedCramSiblingConstraint.kt
@@ -0,0 +1,144 @@
+package gg.essential.elementa.common.constraints
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.ConstraintType
+import gg.essential.elementa.constraints.SiblingConstraint
+import gg.essential.elementa.constraints.WidthConstraint
+import gg.essential.elementa.constraints.resolution.ConstraintVisitor
+
+/**
+ * Note: All items are assumed to be same width
+ */
+class SpacedCramSiblingConstraint(
+ private val minSeparation: WidthConstraint,
+ private val margin: WidthConstraint,
+ private val verticalSeparation: WidthConstraint? = null,
+) :
+ SiblingConstraint() {
+ override var cachedValue = 0f
+ override var recalculate = true
+ override var constrainTo: UIComponent? = null
+
+ override fun getXPositionImpl(component: UIComponent): Float {
+ val index = component.parent.children.indexOf(component)
+
+ val marginPixels = margin.getWidth(component).toInt()
+ val minSeparationPixels = minSeparation.getWidth(component).toInt()
+ val totalWidth = component.parent.getWidth() - marginPixels * 2
+ val itemWidth = component.getWidth()
+ val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt()
+ if (itemsPerRow <= 1) {
+ return component.parent.getLeft() + ((totalWidth - itemWidth) / 2f)
+ }
+ if (index == 0) {
+ return component.parent.getLeft() + marginPixels
+ }
+ val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1)
+ val sibling = component.parent.children[index - 1]
+ if (sibling.getRight() + component.getWidth() + minSeparationPixels <= component.parent.getRight() + floatErrorMargin) {
+ return sibling.getRight() + itemSep
+ }
+
+ return component.parent.getLeft() + marginPixels
+ }
+
+ override fun getYPositionImpl(component: UIComponent): Float {
+ val index = component.parent.children.indexOf(component)
+
+ val marginPixels = margin.getWidth(component).toInt()
+ if (index == 0) {
+ return component.parent.getTop() + marginPixels
+ }
+
+ val minSeparationPixels = minSeparation.getWidth(component).toInt()
+ val totalWidth = component.parent.getWidth() - marginPixels * 2
+ val itemWidth = component.getWidth()
+ val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt()
+ val sibling = component.parent.children[index - 1]
+ if (itemsPerRow <= 1) {
+ return sibling.getBottom() + minSeparationPixels
+ }
+ val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1)
+
+ if (sibling.getRight() + component.getWidth() + minSeparationPixels <= component.parent.getRight() + floatErrorMargin) {
+ return sibling.getTop()
+ } else if (sibling.javaClass != component.javaClass) {
+ // FIXME This workaround is broken and should never have been added in the first place. Instead of mixing
+ // different components with SpacedCramSiblingConstraints, just put a wrapper component around the
+ // grid.
+ // Should be removed once the old CosmeticStudio is dead.
+ // If the previous item not a cosmetic option, position right after it so vertical padding
+ // can be made consistent. Otherwise, `itemSep` can vary and lead to inconsistent padding.
+ return sibling.getBottom()
+ }
+ val verticalSep = verticalSeparation?.getWidth(component.parent)
+ ?: itemSep.coerceAtLeast(minSeparationPixels.toFloat())
+ return getLowestPoint(sibling, component.parent, index) + verticalSep
+ }
+
+ // This allows ChildBasedSizeConstraint to function for the parent height by emitting negative padding for
+ // items that are layed out in-line.
+ // Note: For simplicity this assumes all items are the same size, both horizontally (as the constraint as a whole
+ // already assumes) but also vertically.
+ // It also does not support margin as that's just unnecessary complexity (just add a wrapper if you need it).
+ override fun getVerticalPadding(component: UIComponent): Float {
+ val index = component.parent.children.indexOf(component)
+ if (index == 0) {
+ return 0f
+ }
+
+ val minSeparationPixels = minSeparation.getWidth(component).toInt()
+ val totalWidth = component.parent.getWidth()
+ val itemWidth = component.getWidth()
+ val itemsPerRow = ((totalWidth + minSeparationPixels) / (itemWidth + minSeparationPixels)).toInt()
+ if (itemsPerRow <= 1) {
+ return minSeparationPixels.toFloat()
+ }
+ val itemSep = (totalWidth - itemsPerRow * itemWidth) / (itemsPerRow - 1)
+ if (index % itemsPerRow == 0) {
+ return verticalSeparation?.getWidth(component.parent)
+ ?: itemSep.coerceAtLeast(minSeparationPixels.toFloat())
+ }
+ return -component.getHeight()
+ }
+
+ override fun to(component: UIComponent) = apply {
+ throw UnsupportedOperationException("Constraint.to(UIComponent) is not available in this context!")
+ }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {
+ val indexInParent = visitor.component.let { it.parent.children.indexOf(it) }
+
+ when (type) {
+ ConstraintType.X -> {
+ if (indexInParent == 0) {
+ visitor.visitParent(ConstraintType.X)
+ return
+ }
+
+ visitor.visitSelf(ConstraintType.WIDTH)
+ visitor.visitSibling(ConstraintType.X, indexInParent - 1)
+ visitor.visitSibling(ConstraintType.WIDTH, indexInParent - 1)
+ visitor.visitParent(ConstraintType.WIDTH)
+ visitor.visitParent(ConstraintType.X)
+ }
+ ConstraintType.Y -> {
+ if (indexInParent == 0) {
+ visitor.visitParent(ConstraintType.Y)
+ return
+ }
+
+ visitor.visitSelf(ConstraintType.WIDTH)
+ visitor.visitSibling(ConstraintType.X, indexInParent - 1)
+ visitor.visitSibling(ConstraintType.WIDTH, indexInParent - 1)
+ visitor.visitParent(ConstraintType.WIDTH)
+ visitor.visitParent(ConstraintType.X)
+ }
+ else -> throw IllegalArgumentException(type.prettyName)
+ }
+ }
+
+ companion object {
+ private const val floatErrorMargin = 0.001f
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt
new file mode 100644
index 00000000..85650584
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/common/stateExtensions.kt
@@ -0,0 +1,12 @@
+package gg.essential.elementa.common
+
+import gg.essential.elementa.state.State
+import gg.essential.elementa.state.v2.ReferenceHolder
+
+fun State.onSetValueAndNow(listener: (T) -> Unit) = onSetValue(listener).also { listener(get()) }
+
+@Deprecated("See `State.onSetValue`. Use `stateBy`/`effect` instead.")
+fun gg.essential.elementa.state.v2.State.onSetValueAndNow(owner: ReferenceHolder, listener: (T) -> Unit) =
+ onSetValue(owner, listener).also { listener(get()) }
+
+operator fun State.not() = map { !it }
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt
new file mode 100644
index 00000000..3c1985a2
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/AlphaEffect.kt
@@ -0,0 +1,187 @@
+package gg.essential.elementa.effects
+
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.state.State
+import gg.essential.universal.UGraphics
+import gg.essential.universal.UMatrixStack
+import gg.essential.universal.UResolution
+import gg.essential.universal.shader.BlendState
+import gg.essential.universal.shader.SamplerUniform
+import gg.essential.universal.shader.UShader
+import org.lwjgl.opengl.GL11
+import java.io.Closeable
+import java.lang.ref.PhantomReference
+import java.lang.ref.ReferenceQueue
+import java.nio.ByteBuffer
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Applies an alpha value to a component. This is done by snapshotting the framebuffer behind the component,
+ * rendering the component, then rendering the snapshot with the inverse of the desired alpha.
+ */
+class AlphaEffect(private val alphaState: State) : Effect() {
+ private val resources = Resources(this)
+ private var textureWidth = -1
+ private var textureHeight = -1
+
+ override fun setup() {
+ initShader()
+ Resources.drainCleanupQueue()
+ resources.textureId = GL11.glGenTextures()
+ }
+
+ override fun beforeDraw(matrixStack: UMatrixStack) {
+ if (resources.textureId == -1) error("AlphaEffect has not yet been setup or has already been cleaned up! ElementaVersion.V4 or newer is required for proper operation!")
+
+ val scale = UResolution.scaleFactor
+
+ // Get the coordinates of the component within the bounds of the screen in real pixels
+ val left = (boundComponent.getLeft() * scale).toInt().coerceIn(0..UResolution.viewportWidth)
+ val right = (boundComponent.getRight() * scale).toInt().coerceIn(0..UResolution.viewportWidth)
+ val top = (boundComponent.getTop() * scale).toInt().coerceIn(0..UResolution.viewportHeight)
+ val bottom = (boundComponent.getBottom() * scale).toInt().coerceIn(0..UResolution.viewportHeight)
+
+ val x = left
+ val y = UResolution.viewportHeight - bottom // OpenGL screen coordinates start in the bottom left
+ val width = right - left
+ val height = bottom - top
+
+ if (width == 0 || height == 0 || !shader.usable) {
+ return
+ }
+
+ UGraphics.configureTexture(resources.textureId) {
+ if (width != textureWidth || height != textureHeight) {
+ GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGBA8, width, height, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, null as ByteBuffer?)
+ GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST)
+ GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST)
+ textureWidth = width
+ textureHeight = height
+ }
+
+ GL11.glCopyTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, x, y, width, height)
+ }
+ }
+
+ override fun afterDraw(matrixStack: UMatrixStack) {
+ // Get the coordinates of the component within the bounds of the screen in fractional MC pixels
+ val left = boundComponent.getLeft().toDouble().coerceIn(0.0..UResolution.viewportWidth / UResolution.scaleFactor)
+ val right = boundComponent.getRight().toDouble().coerceIn(0.0..UResolution.viewportWidth / UResolution.scaleFactor)
+ val top = boundComponent.getTop().toDouble().coerceIn(0.0..UResolution.viewportHeight / UResolution.scaleFactor)
+ val bottom = boundComponent.getBottom().toDouble().coerceIn(0.0..UResolution.viewportHeight / UResolution.scaleFactor)
+
+ val x = left
+ val y = top
+ val width = right - left
+ val height = bottom - top
+
+ if (width == 0.0 || height == 0.0 || !shader.usable) {
+ return
+ }
+
+ val red = 1f
+ val green = 1f
+ val blue = 1f
+ val alpha = 1f - alphaState.get()
+
+ var prevAlphaTestFunc = 0
+ var prevAlphaTestRef = 0f
+ if (!UGraphics.isCoreProfile()) {
+ prevAlphaTestFunc = GL11.glGetInteger(GL11.GL_ALPHA_TEST_FUNC)
+ prevAlphaTestRef = GL11.glGetFloat(GL11.GL_ALPHA_TEST_REF)
+ UGraphics.alphaFunc(GL11.GL_ALWAYS, 0f)
+ }
+
+ shader.bind()
+ textureUniform.setValue(resources.textureId)
+
+ val worldRenderer = UGraphics.getFromTessellator()
+ worldRenderer.beginWithActiveShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR)
+ worldRenderer.pos(matrixStack, x, y + height, 0.0).tex(0.0, 0.0).color(red, green, blue, alpha).endVertex()
+ worldRenderer.pos(matrixStack, x + width, y + height, 0.0).tex(1.0, 0.0).color(red, green, blue, alpha).endVertex()
+ worldRenderer.pos(matrixStack, x + width, y, 0.0).tex(1.0, 1.0).color(red, green, blue, alpha).endVertex()
+ worldRenderer.pos(matrixStack, x, y, 0.0).tex(0.0, 1.0).color(red, green, blue, alpha).endVertex()
+ worldRenderer.drawDirect()
+
+ shader.unbind()
+
+ if (!UGraphics.isCoreProfile()) {
+ UGraphics.alphaFunc(prevAlphaTestFunc, prevAlphaTestRef)
+ }
+ }
+
+ fun cleanup() {
+ resources.close()
+ }
+
+ private class Resources(effect: AlphaEffect) : PhantomReference(effect, referenceQueue), Closeable {
+ var textureId = -1
+
+ init {
+ toBeCleanedUp.add(this)
+ }
+
+ override fun close() {
+ toBeCleanedUp.remove(this)
+
+ if (textureId != -1) {
+ GL11.glDeleteTextures(textureId)
+ textureId = -1
+ }
+ }
+
+ companion object {
+ val referenceQueue = ReferenceQueue()
+ val toBeCleanedUp: MutableSet = Collections.newSetFromMap(ConcurrentHashMap())
+
+ fun drainCleanupQueue() {
+ while (true) {
+ ((referenceQueue.poll() ?: break) as Resources).close()
+ }
+ }
+ }
+ }
+
+ companion object {
+ private lateinit var shader: UShader
+ private lateinit var textureUniform: SamplerUniform
+
+ private fun initShader() {
+ if (::shader.isInitialized) return
+
+ shader = UShader.fromLegacyShader("""
+ #version 110
+
+ varying vec2 f_Position;
+ varying vec2 f_TexCoord;
+
+ void main() {
+ f_Position = gl_Vertex.xy;
+ f_TexCoord = gl_MultiTexCoord0.st;
+
+ gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
+ gl_FrontColor = gl_Color;
+ }
+ """.trimIndent(), """
+ #version 110
+
+ uniform sampler2D u_Texture;
+
+ varying vec2 f_Position;
+ varying vec2 f_TexCoord;
+
+ void main() {
+ gl_FragColor = gl_Color * vec4(texture2D(u_Texture, f_TexCoord).rgb, 1.0);
+ }
+ """.trimIndent(), BlendState.NORMAL, UGraphics.CommonVertexFormats.POSITION_TEXTURE_COLOR)
+
+ if (!shader.usable) {
+ println("Failed to load AlphaEffect shader")
+ return
+ }
+
+ textureUniform = shader.getSamplerUniform("u_Texture")
+ }
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt
new file mode 100644
index 00000000..e1e25030
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/effects/GradientEffect.kt
@@ -0,0 +1,110 @@
+package gg.essential.elementa.effects
+
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.state.v2.State
+import gg.essential.universal.UGraphics
+import gg.essential.universal.UMatrixStack
+import gg.essential.universal.shader.BlendState
+import gg.essential.universal.shader.UShader
+import org.intellij.lang.annotations.Language
+import org.lwjgl.opengl.GL11
+import java.awt.Color
+
+/**
+ * Draws a gradient (smooth color transition) behind the bound component.
+ *
+ * Unlike [gg.essential.elementa.components.GradientComponent], this effect also applies dithering to the gradient to
+ * mitigate color banding artifacts.
+ *
+ * Note: The behavior of non-axis-aligned gradients (e.g. more than two colors, or diagonal) is currently undefined.
+ */
+class GradientEffect(
+ private val topLeft: State,
+ private val topRight: State,
+ private val bottomLeft: State,
+ private val bottomRight: State,
+) : Effect() {
+ override fun beforeChildrenDraw(matrixStack: UMatrixStack) {
+ val topLeft = this.topLeft.get()
+ val topRight = this.topRight.get()
+ val bottomLeft = this.bottomLeft.get()
+ val bottomRight = this.bottomRight.get()
+
+ val dither = topLeft != topRight || topLeft != bottomLeft || bottomLeft != bottomRight
+ if (dither) {
+ shader.bind()
+ }
+
+ val buffer = UGraphics.getFromTessellator()
+ if (dither) {
+ buffer.beginWithActiveShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_COLOR)
+ } else {
+ buffer.beginWithDefaultShader(UGraphics.DrawMode.QUADS, UGraphics.CommonVertexFormats.POSITION_COLOR)
+ }
+
+ val x1 = boundComponent.getLeft().toDouble()
+ val x2 = boundComponent.getRight().toDouble()
+ val y1 = boundComponent.getTop().toDouble()
+ val y2 = boundComponent.getBottom().toDouble()
+
+ buffer.pos(matrixStack, x2, y1, 0.0).color(topRight).endVertex()
+ buffer.pos(matrixStack, x1, y1, 0.0).color(topLeft).endVertex()
+ buffer.pos(matrixStack, x1, y2, 0.0).color(bottomLeft).endVertex()
+ buffer.pos(matrixStack, x2, y2, 0.0).color(bottomRight).endVertex()
+
+ var prevAlphaTestFunc = 0
+ var prevAlphaTestRef = 0f
+ if (!UGraphics.isCoreProfile()) {
+ prevAlphaTestFunc = GL11.glGetInteger(GL11.GL_ALPHA_TEST_FUNC)
+ prevAlphaTestRef = GL11.glGetFloat(GL11.GL_ALPHA_TEST_REF)
+ UGraphics.alphaFunc(GL11.GL_ALWAYS, 0f)
+ }
+
+ // See UIBlock.drawBlock for why we use this depth function
+ UGraphics.enableDepth()
+ UGraphics.depthFunc(GL11.GL_ALWAYS)
+ buffer.drawDirect()
+ UGraphics.disableDepth()
+ UGraphics.depthFunc(GL11.GL_LEQUAL)
+
+ if (!UGraphics.isCoreProfile()) {
+ UGraphics.alphaFunc(prevAlphaTestFunc, prevAlphaTestRef)
+ }
+
+ if (dither) {
+ shader.unbind()
+ }
+ }
+
+ companion object {
+ @Language("GLSL")
+ private val vertSource = """
+ varying vec4 vColor;
+
+ void main() {
+ gl_Position = gl_ProjectionMatrix * gl_ModelViewMatrix * gl_Vertex;
+ vColor = gl_Color;
+ }
+ """.trimIndent()
+
+ @Language("GLSL")
+ private val fragSource = """
+ varying vec4 vColor;
+
+ void main() {
+ // Generate four pseudo-random values in range [-0.5; 0.5] for the current fragment coords, based on
+ // Vlachos 2016, "Advanced VR Rendering"
+ vec4 noise = vec4(dot(vec2(171.0, 231.0), gl_FragCoord.xy));
+ noise = fract(noise / vec4(103.0, 71.0, 97.0, 127.0)) - 0.5;
+
+ // Apply dithering, i.e. randomly offset all the values within a color band, so there are no harsh
+ // edges between different bands after quantization.
+ gl_FragColor = vColor + noise / 255.0;
+ }
+ """.trimIndent()
+
+ private val shader: UShader by lazy {
+ UShader.fromLegacyShader(vertSource, fragSource, BlendState.NORMAL, UGraphics.CommonVertexFormats.POSITION_COLOR)
+ }
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md
new file mode 100644
index 00000000..0b5c1b0b
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/README.md
@@ -0,0 +1,1098 @@
+# Layout DSL
+
+The Layout DSL provides a high-level DSL to declare the overall structure and layout of Elementa components or entire
+screens.
+
+Note: This document does not cover the `State` API (in part because State V2 is still being worked on).
+ The Layout DSL does make heavy use of it though (at least for anything dynamic). Until someone writes a guide for
+ it, here's a one paragraph primer:
+ Most of the high-level state of your GUI lives in multiple `MutableState` instances, one per factum. You then
+ `map` or `zip` these to derive various other `State`s (like the text in a specific gui label) from them. Most gui
+ components as well as the dynamic parts of the Layout DSL can accept `State`s and will then automatically update
+ whenever you change any of your `MutableState`s. There's also special support for `List`s in states (though V1's
+ special case for this, ListState, has various footguns), with V2 also for `Set`s in states. And the last thing
+ that should be mentioned is `stateBy` for when `map` and `zip` don't cut it (may actually become the new standard
+ for V2, still to be decided).
+ For something more detailed, take a look at the public members of the main file (and potentially other files) of
+ State V2 [1].
+ V1 is similar but more messy, see [2] for an overview of differences.
+ Though you will still need to look at various existing uses (outside of Elementa where backwards-compatibility
+ complicates everything; would recommend the Wardrobe as it's the most recently written one and also uses the
+ Layout DSL) to see something real.
+
+[1]: https://github.com/EssentialGG/Elementa/blob/feature/state-v2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt
+[2]: https://github.com/EssentialGG/Elementa/pull/88#issue-1347835067
+
+## Motivation
+
+This section explains the main problem(s) the Layout DSL was meant to solve by starting from a regular, old Elementa
+example and gradually transforming it to use the Layout DSL instead.
+This assumes you have at least a rough idea of how Elementa components and constraints work.
+
+The main issue the Layout DSL tries to solve is that in a regular Elementa screen, which consists of many sub-components
+are that usually all declared in a field of the screen class, it is difficult to grasp how all these components relate
+to each other without carefully following all `childOf` calls while also keeping an eye on all the constraints at all
+times.
+And the constraints part is not to be underestimated because the way Elementa constraints are currently declared does
+not make it particularly easy to understand the layout of a particular component's children from just glancing at one
+child.
+
+Consider for example this relatively simple screen that's just two equally-sized boxes (as big as possible with a fixed
+padding) next to each other, the left has a centered text that reads Left, the right one is split vertically with the
+center of the top half saying Top and the bottom half saying Bottom:
+```
+|-----------------------------|
+| |
+| |----------| |----------| |
+| | | | | |
+| | | | TOP | |
+| | LEFT | | | |
+| | | | BOTTOM | |
+| | | | | |
+| |----------| |----------| |
+| |
+|-----------------------------|
+```
+
+The regular Elementa code for this would look something like this:
+```kotlin
+val window: Window = WindowScreen()
+
+// This extra wrapper may seem redundant here, the reason we have it will become clear later
+val wrapper by UIContainer().constrain {
+ width = 100.percent
+ height = 100.percent
+} childOf window
+
+val content by UIContainer().constrain {
+ x = CenterConstraint()
+ y = CenterConstraint()
+ width = 100.percent - 2.pixels
+ height = 100.percent - 2.pixels
+} childOf wrapper
+
+val left by UIContainer().constrain {
+ width = 50.percent - 1.5.pixels
+ height = 100.percent
+} childOf content
+
+val right by UIContainer().constrain {
+ x = 0.pixels(alignOpposite = true)
+ width = 50.percent - 1.5.pixels
+ height = 100.percent
+} childOf content
+
+val leftText by UIText("Left").constrain {
+ x = CenterConstraint()
+ y = CenterConstraint()
+} childOf left
+
+val top by UIContainer().constrain {
+ width = 100.percent
+ height = 50.percent
+} childOf right
+
+val bottom by UIContainer().constrain {
+ y = 0.pixels(alignOpposite = true)
+ width = 100.percent
+ height = 50.percent
+} childOf right
+
+val topText by UIText("Top").constrain {
+ x = CenterConstraint()
+ y = CenterConstraint()
+} childOf top
+
+val bottomText by UIText("Bottom").constrain {
+ x = CenterConstraint()
+ y = CenterConstraint()
+} childOf bottom
+```
+
+And this kind of code is **extremely** common since almost everything in most UIs is hierarchical.
+But, without the ascii sketch above, it's unreasonably difficult to tell what this will actually look like until you
+run it (or, with quite some effort, mentally evaluate it).
+
+It shouldn't be hard to imagine how bad this can get with more complex layouts.
+It gets even worse once you start making some things dynamic because then you really need to go searching to find the
+parent/children.
+And reading the constraints can become quite difficult too because Elementa does not at all force you to actually use
+additional wrapper components to define the layout, you could (and frequently it's convenient in the short term) totally
+just define the three text components and give them highly complex constraints which compute the same thing.
+
+In the simplest case, layout dsl can at least allow you to more easily understand the parent-child relations.
+For this first version, we effectively keep everything from above except for the `childOf` calls which are now handled
+by the layout dsl:
+```kotlin
+window.layout {
+ wrapper {
+ content {
+ left {
+ leftText()
+ }
+ right {
+ top {
+ topText()
+ }
+ bottom {
+ bottomText()
+ }
+ }
+ }
+ }
+}
+```
+With that, it is immediately clear now that the top and bottom parts are children of the right side only.
+But, if we did not name our components by their direction, it would still be difficult to tell whether things are layed
+out vertically, horizontally or some other way. Additionally, things like size and alignment also still require you to
+look at the component definitions.
+
+This is where `Modifier`s come in. A modifier expresses a set of configurations/modifications that one wishes to apply
+to a given component. There are modifiers for a variety of things like position, size, color, effects, callbacks, etc.
+Modifiers can be chained together so you get a single modifier that applies multiple modifications.
+There even exist higher-order modifiers that e.g. apply a given modifier only while the component is being hovered.
+
+For now, let's use only the basic ones to exactly replicate the above example, but this time we can also remove the
+`constrain` blocks from the original code:
+```kotlin
+val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight()
+window.layout {
+ wrapper(Modifier.fillParent()) {
+ content(Modifier.alignBoth(Alignment.Center).fillParent(padding = 1f)) {
+ left(Modifier.alignHorizontal(Alignment.Start).then(halfWidth)) {
+ leftText(Modifier.alignBoth(Alignment.Center))
+ }
+ right(Modifier.alignHorizontal(Alignment.End).then(halfWidth)) {
+ top(Modifier.alignHorizontal(Alignment.Start).fillWidth().fillHeight(0.5f)) {
+ topText(Modifier.alignBoth(Alignment.Center))
+ }
+ bottom(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) {
+ bottomText(Modifier.alignBoth(Alignment.Center))
+ }
+ }
+ }
+ }
+}
+```
+
+You may notice that while some of these map relatively directly on existing constraints (e.g.
+`fillWidth(fraction, padding)` is just `fraction.percent - (padding * 2).pixels`), there are also plenty higher-level
+modifiers (e.g. `fillParent` which is both `fillWidth` as well as `fillHeight`) to reduce repetition and make it easier
+to understand what a modifier does at first glance.
+There are also modifiers that let you set constraints directly (`BasicXModifier`) but these exist only as an escape
+hatch and you should ideally never need them.
+
+Ok, so the above is definitely more compact than the original code, and you can kind of tell the general layout if you
+look carefully at the modifiers. But we can still do **a lot** better.
+
+For starters, all that is left in the fields at this point are the constructor calls, so it might be tempting to inline
+them. And generally there's nothing wrong with this as long as they really are as simple as in the example.
+If there's still a lot of component configuration left, for example click handlers, then you'll usually want to keep the
+fields as to not blow up the DSL block (remember: it is meant to show the layout, not every last details; and it is
+supposed to be easy to grasp as a whole, a 50 line click handler in the middle makes that a lot harder).
+
+```kotlin
+// With fields:
+bottom(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) {
+ bottomText(Modifier.alignBoth(Alignment.Center))
+}
+// Constructors inlined:
+UIComponent()(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) {
+ UIText("Bottom")(Modifier.alignBoth(Alignment.Center))
+}
+// Because text and simple containers are quite common, there exist `box` and `text` methods which will create the
+// components with the given modifiers.
+box(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) {
+ text("Bottom", modifier = Modifier.alignBoth(Alignment.Center))
+}
+```
+
+Next, notice how there's quite a lot of `align(Center)`?
+That's actually quite common and arguably because Elementa has bad default constraints.
+Yes, `0.pixels` can make sense some times, but usually, if it is the only child and there is wiggle room in the parent,
+you want your components to be centered, you don't want everything slanted to the left.
+
+To remedy this, any children of `box` will automatically be centered by default. You can still overwrite the positioning
+by explicitly specifying an alignment as above, but the default is already what you want in quite a lot of cases.
+
+The default for size in Elementa is even worse. You practically never want your component to be `0.pixels` in size, yet
+that's what you get by default.
+Layout DSL improves that as well: The default size of a `box` is `ChildBasedSizeConstraint()`. It doesn't come up in our
+example because it is sized fully top-down but for components that are sized bottom-up, this is a much better default
+than the useless `0.pixels`.
+
+Applying this to our example, we get:
+```kotlin
+val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight()
+window.layout {
+ box(Modifier.fillParent()) {
+ box(Modifier.fillParent(padding = 1f)) {
+ box(Modifier.alignHorizontal(Alignment.Start).then(halfWidth)) {
+ text("Left")
+ }
+ box(Modifier.alignHorizontal(Alignment.End).then(halfWidth)) {
+ box(Modifier.alignHorizontal(Alignment.Start).fillWidth().fillHeight(0.5f)) {
+ text("Top")
+ }
+ box(Modifier.alignHorizontal(Alignment.End).fillWidth().fillHeight(0.5f)) {
+ text("Bottom")
+ }
+ }
+ }
+ }
+}
+```
+
+Note: We cannot, at this point, get rid of an `alignBoth` on the outer-most box because its parent is a `Window`, not
+ another `box`. This case doesn't actually happen very often in practice because it only happens on the outermost
+ Layout DSL layer, and if you're building a component to be used by other code, then that other code is usually the
+ one that specifies the position for you.
+ This is why we introduced the extra `wrapper` component in our original example, with it being full-size, we don't
+ need to align it. And more importantly, everything inside of it can fully use the Layout DSL with no distractions.
+
+The final two observations: There's still quite a lot of `align` happening, and unlike the field names `box` doesn't
+really tell us anything about the relative positioning of the components, we have to look at the specific `align` calls.
+
+But there's no reason we can't introduce more methods like `box` with more meaningful names and more defaults. The two
+common builtin ones are `row` and `column`:
+
+```kotlin
+val halfWidth = Modifier.fillWidth(fraction = 0.5f, padding = 1.5f).fillHeight()
+window.layout {
+ box(Modifier.fillParent()) {
+ row(Modifier.fillParent(padding = 1f), Arrangement.SpaceBetween) {
+ box(halfWidth) {
+ text("Left")
+ }
+ column(halfWidth) {
+ box(Modifier.fillWidth().fillHeight(0.5f)) {
+ text("Top")
+ }
+ box(Modifier.fillWidth().fillHeight(0.5f)) {
+ text("Bottom")
+ }
+ }
+ }
+ }
+}
+```
+
+`row` and `column`, unlike `box`, use a new `Arrangement`-based layout system on their primary axis and the already
+introduced `Alignment`s in another optional argument as the default for their secondary axis (defaulting to Center if
+not specified).
+
+They also have different default sizes, their primary axis being sized by the same `Arrangement` system with their
+secondary axis sized by a `ChildBasedMaxSizeConstraint` (i.e. a `row` is as high as its highest child and as wide as all
+its children together, plus some additional spacing depending on the arrangement).
+
+## Usage
+
+### LayoutScope
+
+The Layout DSL may be used to lay out the children of any Elementa component.
+To use it, simply call the `layout` extension function on the component.
+
+The `layout` function takes an optional `Modifier` to be applied to the component itself as well as a block which, via
+its receiver, has access to a `LayoutScope` instance through which it can add children to the component:
+```kotlin
+val myComponent = UIContainer()
+val myChild = UIContainer()
+val myInnerChild = UIContainer()
+
+myComponent.layout(Modifier.width(100).height(20)) {
+ // Adds `myChild` as a child of `myComponent`
+ invoke(myChild)
+ // Or, because it's actually an extension function on UIComponent, one could also call it like this:
+ myChild.invoke()
+ // The name may seem a bit odd, but that's because it's also an operator function,
+ // so the normal way to call it is actually just:
+ myChild()
+
+ // This call may also receive a Modifier to be applied to the child as well as a block that opens another
+ // `LayoutScope`, this time for the child:
+ myChild(Modifier.fillParent()) {
+ // Adds `myInnerChild` as a child of `myChild`
+ myInnerChild()
+ }
+
+ // But `myChild` doesn't have to be declared in a variable outside, it could also be declared inline, though
+ // this is usually discouraged if it's more than just a simple constructor call:
+ UIContainer()()
+ // Note the double `()`: the first one is the constructor `UIContainer` call, the second is the call to `invoke`
+ // that adds it as a child and can receive a Modifier and a block that opens another layout scope.
+}
+```
+
+### Modifier
+
+A `Modifier` expresses a set of configurations/modifications that one wishes to apply to a given component.
+There are modifiers for a variety of things like position, size, color, effects, callbacks, etc.
+
+One modifier, unlike a `Constraint`, is also explicitly meant to be re-usable on multiple components, such that a more
+complex modifier can be build once, stored in a variable and then re-used for multiple components.
+
+#### DSL
+
+Modifiers have their own mini-DSL that allows them to be easily composed.
+
+You can get an empty modifier that does nothing with simply `Modifier` (syntactically, that's the companion object of
+thi `Modifier` interface). You can then call various extension methods on this `Modifier` to add extra modifications
+that should happen, like `Modifier.width(100).height(20)`.
+
+So with most modifiers, you don't actually get a `Modifier` instance directly, you merely get a constructor extension
+method to tag the modifier onto your existing modifier because that's usually more convenient.
+
+When you do however have two modifiers instances that you want to chain together, you can do so via the `then` method,
+like `modifierA.then(modifierB)`. The resulting modifier will first apply all modifications from `modifierA` and then
+all modifications from `modifierB`. You can also call `then` as an infix operator, like `modifierA then modifierB`.
+
+Most of the extension methods should simply be defined as `fun Modifier.something() = this then ...`.
+
+#### Sizing modifiers
+
+The two main ways to size component hierarchies is either top-down or bottom-up, i.e. either the parent component has a
+fixed size (such as the screen) and its children try to grow as big as there is space, or the child has a fixed size and
+the parent tries to shrink as far as possible while still containing the child.
+
+Usually any given screen will make use of both methods and they will meet at some point in the middle, e.g. a button is
+sized as big as the text it contains plus some padding but the container in which the button resides in is as big as
+possible (and the button may for example be centered within it).
+
+The point at which these meet is also frequently different depending on the axis. E.g. a button may be as high as
+required by its text but as wide as its parent permits.
+
+Elementa does not currently allow for both approaches to be applied to the same component at the same time.
+
+##### Fixed size
+
+The `width`/`height` modifiers will assign a fixed size in pixels to a component.
+
+They do have overloads that accept another component and copy its size, though these are rarely used.
+
+Not quite fixed but dependent on neither parent nor children, the `widthAspect`/`heightAspect` modifiers will set the
+width/height of a component to a multiple of its height/width.
+
+##### Top-down
+
+The single most common modifier for trying to grow a component as big as its parent permits is `fillParent`.
+Its first argument is the fraction it should attempt to grow to (e.g. `0.5` would make it grow to 50% of the parent's
+size).
+Its second argument is a fixed padding in pixels that it should maintain on each side.
+
+E.g. if the parent is 10 pixels wide, then with `Modifier.fillParent(0.5, 1)` the child will be `0.5 * 10 - 1 * 2 = 3`
+
+Similarly `fillWidth` and `fillHeight` can be used to configure a single axis.
+
+Another, less commonly used but still important modifier is `fillRemainingWidth`/`fillRemainingHeight` which can only
+be used by a single child and will cause that child to take up any remaining space in the parent.
+
+##### Bottom-up
+
+The `childBasedWidth`/`childBasedHeight` will size the component to match the total size of its children along the
+respective axis.
+The `childBasedMaxWidth`/`childBasedMaxHeight` will size the component to match the biggest of its children along the
+respective axis.
+
+Both of the above accept an optional `padding` parameter which will add an extra, fixed amount of pixels for each side
+to the width/height of this component. That is, the padding is between this component and all its children. Not between
+any two children individually: `this.width = padding + sum(child.width) + padding`.
+
+It should be noted that, unless you're using the padding parameter, you usually don't need to explicitly use any of
+these because the common `box`, `row`, and `column` containers use them by default.
+
+#### Alignment modifiers
+
+If there is more spaces in your parent than your child needs, you may need to specify how it should be aligned inside
+its parent. The `alignHorizontal`/`alignVertical` modifiers will set the `x`/`y` position of the component according to
+the given `Alignment`. The `alignBoth` modifier will use the same alignment for both axes.
+
+Note that the common `box` container, as well as the secondary axes of `row` and `column` already use
+`Aligntment.Center` by default for all their children, so often you do not need to explicitly set the alignment.
+The primary axes of `row` and `column` use the `Arrangement` system for positioning, so Alignment does not apply there.
+
+The three most common alignments are `Start`, `Center` and `End`.
+
+`Start` and `End` can additionally accept an optional `padding` parameter.
+
+`Center` puts the component in the center of its parent aligned to the nearest full MC pixel in the context of its
+parent. E.g. if the component is 2 in height and its parent is 5 in height, then this will place the component at one
+pixel distance from the top of its parent, and two pixels from the bottom.
+This is usually preferred design-wise.
+You can get the true center (1.5 in above example) with the `TrueCenter` alignment.
+
+#### Constraint modifiers
+
+There exist `BasicXModifier` where `X` may be replaced with any of the standard constraint types which simply set the
+given constraint on the component.
+
+Note that usually you shouldn't need these, there's usually a more high-level modifier or container you can use instead.
+These only exist as an escape hatch.
+
+#### Conditional modifiers
+
+Modifiers can be dynamically applied and reverted in response to the value of a `State`.
+
+The main primitive is an overload of `then` that takes a `State` as its argument and applies the modifier in
+the State, reverting it and re-applying whenever the State changes.
+
+For the special case of `State` a `whenTrue` modifier exists that applies a given modifier only while the given
+state is `true` (and optionally applies a different modifier while it isn't).
+
+#### Event modifiers
+
+There exist modifiers that register a callback on the component for various events such as mouse enter, mouse leave,
+left click, etc.
+
+Note that you usually don't want to use the mouse enter/leave callbacks because they are rather coarse, see the Hovering
+section instead.
+
+#### Custom modifiers
+
+So what exactly is a modifier? How would I define my own?
+Simply put, it's a function that can apply a change to a component, and returns another function to undo the change
+again:
+```kotlin
+interface Modifier {
+ fun applyToComponent(component: UIComponent): () -> Unit
+}
+```
+
+If it is not possible to cleanly undo the change, or if it is difficult to implement and highly unlikely to ever be
+used, the undo function may simply throw an `UnsupportedOperationException`.
+
+There even exists an overload of `then` that takes such a function directly, so you can easily define your own modifier
+extensions like this:
+```kotlin
+fun Modifier.something() = this then {
+ // The component is passed as the receiver, so you can simply call its methods
+ val orgConstraint = constraints.x
+ constrain {
+ // Do keep in mind that modifiers are supposed to be re-usable, so you need to create a new constraint here
+ // every time, you cannot for example re-use a single constraint passed via arguments.
+ // That's why the BasicXModifier takes a constraint factory as its argument rather than a single constraint.
+ x = 10.pixels
+ }
+
+ // And finally return a function that will clean up your change (or throw a NotImplementedError if your modifier
+ // can/does not support that)
+ {
+ constrain {
+ x = orgConstraint
+ }
+ }
+}
+```
+
+### Containers
+
+While not strictly enforced by Elementa, a component tree is generally built from a whole bunch of arbitrarily nested
+containers (tree nodes) with content components (tree leafs) at the bottom.
+
+Most UIs can be broken down into just three types of fundamental container types:
+- Simple `row`s that contain multiple children left to right
+- Simple `column`s that contain multiple children top to bottom
+- Plain `box`es that contain one or more children in no particular layout
+
+Due to how common these are, the Layout DSL has dedicated methods to easily create them and most importantly apply their
+layouts in an intuitive way.
+
+```kotlin
+window.layout {
+ box(Modifier.width(500).height(500)) {
+ column {
+ row {
+ text("top left")
+ text("top right")
+ }
+ text("*second row*")
+ }
+ }
+}
+```
+
+If for some reason you need to refer to one of these at a later point, you can store their return value in a local
+variable or field:
+```kotlin
+val wrapper: UIComponent
+val content: UIComponent
+window.layout {
+ wrapper = box(Modifier.width(500).height(500)) {
+ content = column {
+ // ...
+ }
+ }
+}
+```
+
+#### box
+
+A `box` is a plain container, fairly similar to `UIContainer`.
+It does however have different defaults for its size as well as the position of all its children, and it functions as
+expected with the `color` modifier (it's more like `UIBlock` in that respect).
+
+By default a `box` will try to match the size of its children. Or rather, child, because if there are multiple things
+that should go into the box, it's usually better to wrap those with either `row` or `column`.
+`box` is usually only used to add padding or a fixed size and/or background color.
+
+The default position for children of `box` is `Alignment.Center`.
+
+E.g. a button 100x20 with a 1px outline:
+```kotlin
+box(Modifier.width(100f).height(20f).color(outlineColor)) {
+ box(Modifier.fillParent(padding = 1f).color(backgroundColor)) {
+ text(label)
+ }
+}
+```
+
+#### row
+
+A `row` is a container fairly similar to `box` except that it is meant to handle multiple children arranged horizontally
+in some way.
+As such, it can accept not just a modifier but also a horizontal `Arrangement` and a default vertical `Alignment`.
+
+By default a `row` will try to match the height of its tallest child and the width of all its children summed up plus
+any padding as specified by the arrangement.
+That is, it will try to be as small as it can be, just like all the other common containers.
+
+If a child is less tall than its parent row, i.e. if it could float up and down, it will be positioned vertically
+according to the passed `Alignment` (unless a different alignment was applied to the specific child directly).
+
+The way surplus space is distributed on the main, horizontal axis is determined by the `Arrangement`.
+See the Arrangement section for more information.
+
+Note: Currently the default Alignment is `spacedBy(0, FloatPosition.Left)`, this may be changed to
+ `FloatPosition.Center` in the future.
+
+#### column
+
+See `row` and swap horizontal and vertical.
+
+#### flowContainer
+
+A `flowContainer` acts similar to a `row` except that it expects to be limited in width and will start new rows when
+no more items fit into the current one.
+
+The `minSeparation` argument determines the minimal horizontal padding between any two children in the same row.
+The `verticalSeparation` argument determines the vertical padding between rows.
+
+Note: This container is likely subject to change in the future because its design wasn't very thought out and it
+ currently only serves a single use-case.
+ In particular it currently suffers from the following assumptions:
+
+- it assumes that all children are the same size
+- it assumes `Arrangement.SpaceBetween` for any surplus space
+- it assumes the row as its primary axis, there's no way to change it to fill columns first
+
+#### scrollable
+
+A `scrollable` is like a `box` with a single child which can be scrolled vertically and/or horizontally if it is larger
+than the scrollable on the given axis.
+Content that ends up outside the bounds of the scrollable will not be rendered.
+
+If the child is smaller than the parent, it will by default be centered (just like `box`).
+If you wish to have multiple children, it is recommended that you wrap them in a `column`, `row` or other container
+according to your needs as there is no way to change the default arrangement of the scrollable.
+
+```kotlin
+scrollable(Modifier.fillHeight(), vertictal = true) {
+ column {
+ text("top")
+ spacer(height = 1000)
+ text("bottom")
+ }
+}
+```
+
+Note: This component has not yet seen much use and may still need some refinement.
+
+Note: The `scrollable` method returns an instance of `ScrollComponent`. This may change in the future and you are
+ advised to refrain from using most of its functionality as it is very overloaded and will often act different
+ than what you would expect.
+ Generally the only things that are safe to use are the scroll events and `scrollTo`-type methods.
+
+#### lazyBox
+
+Lazily initializes the inner scope by first only placing a `box` as described by the given `modifier` without any
+children and only initializing the inner scope once that box has been rendered once.
+
+This should be a last reserve for initializing a large list of poorly optimized components, not a common shortcut to
+"make it not lag". Properly profiling and fixing initialization performance issues should always be preferred.
+
+### Content
+
+Similar to the previous "Containers" section, while one could just declare all their components in a field or directly
+in-line, some components are so common that more convenient shorthands exist.
+
+There's not really anything special about most of these, so they don't need much explanation:
+- `text`: Creates single line of text (`EssentialUIText`)
+- `wrappedText`: Creates text that wraps into multiple lines if there is not enough space in its parent (`EssentialUIWrappedText`)
+- `icon`: Creates an icon with a shadow (`ShadowIcon`)
+
+#### spacer
+
+The `spacer` method creates simple, invisible, one-dimensional components. Their sole purpose is to take up a specific
+amount of space at one specific place anywhere between/before/after regular components/containers.
+
+```kotlin
+row {
+ spacer(width = 2f)
+ text("Hello")
+ spacer(width = 10f)
+ text("World")
+}
+// results in: | Hello World|
+```
+
+When to use `spacer` or `Arrangement` often depends more on the intend behind the layout than anything else.
+If you just want some arbitrary amount of extra space somewhere, then `spacer` is probably want you want.
+If you want there to be a symmetrical padding inside your component, then maybe `spacer` isn't the best for the job.
+
+If you have non-symmetrical padding, frequently that can be broken down into a symmetrical part and an extra part (but
+only do so if that makes from a layout point of view), and then both can be wrapped up into a `row` or `container`
+depending on the axis you're working with.
+
+```kotlin
+// V V one space each
+// | a b c |
+// ^ 7 spaces ^ 2 spaces
+// could be written as:
+row {
+ spacer(width = 7f)
+ row(Arrangement.spacedBy(1f)) {
+ text("a")
+ text("b")
+ text("c")
+ }
+ spacer(width = 2f)
+}
+// and depending on why you want the space to be there, that may be totally reasonable.
+// But if parts of the space are meant as padding around the text, and the remainder is just to keep space from
+// whatever is to the left of the row, then introducing another container may be preferable as now if we want to
+// increase the padding around the content, we don't have to modify two magic numbers:
+row {
+ spacer(width = 5f)
+ box(Modifier.childBasedWidth(padding = 2f)) {
+ row(Arrangement.spacedBy(1f)) {
+ text("a")
+ text("b")
+ text("c")
+ }
+ }
+}
+```
+
+Frequently, introducing another layer, even if it is seemingly redundant based on what is drawn, does actually make more
+sense than using spacers because it has semantic significance in the layout.
+
+The only pattern that should categorically be avoided is using spacer in a `row`/`column` that itself is using
+`spacedBy` or a top-down layout with surplus space to contribute, except in the case where the spacer actually
+represents an empty entry in the container.
+For the above example, this would be:
+```kotlin
+// This does give the same result as above, but neither of the spacers represents anything tangible and the actual space
+// before / after the text is different than what you would think after quickly skimming the code.
+row(Arrangement.spacedBy(1f)) {
+ spacer(width = 6f)
+ text("a")
+ text("b")
+ text("c")
+ spacer(width = 1f)
+}
+```
+
+#### scrollGradient
+
+The `scrollGradient` method adds a shadow-like gradient at the top and the bottom of a `scrollable`.
+The gradient will fade in/out as you the scrollable is scrolled. That is, the top gradient won't be visible if it is
+scrolled to the very top, and the bottom gradient will become invisible when it is scrolled to the very bottom.
+
+They will usually be added directly after the scroller in a shared box that matches the size of the scrollable:
+```kotlin
+box(Modifier.fillParent()) {
+ val scroller = scrollable(Modifier.fillParent(), vertictal = true) {
+ column {
+ text("top")
+ spacer(height = 1000)
+ text("bottom")
+ }
+ }
+
+ val gradientHeight = Modifier.height(30)
+ scrollGradient(scroller, top = true, gradientHeight)
+ scrollGradient(scroller, top = false, gradientHeight)
+}
+```
+
+Note: This component has not yet seen much use and may still need some refinement.
+
+#### Custom components
+
+##### Function components
+
+While the regular sub-class way of creating custom Elementa components can be used just fine with the Layout DSL, a
+pattern that's ofter easier is to simply pull out certain parts of your Layout DSL tree into separate functions:
+
+```kotlin
+fun LayoutScope.button(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ box(Modifier.width(100f).height(20f).color(Palette.buttonOutlineColor).onLeftClick(onClick).then(modifier)) {
+ box(Modifier.fillParent(padding = 1f).color(Palette.buttonBackgroundColor)) {
+ text(label)
+ }
+ }
+}
+
+window.layout {
+ column(Arrangement.spacedBy(5f)) {
+ row(Arrangement.spacedBy(3f)) {
+ button("Yes", ::accept)
+ button("No", ::reject)
+ }
+ button("Cancel", ::cancel, Modifier.width(30f).height(10f))
+ }
+}
+```
+
+There is nothing magical about these functions.
+They are just regular extension functions which have `LayoutScope` as their receiver and follow the general feel of
+builtin content or container methods.
+
+They will frequently live either as inner functions (if the component is specific to one use-case) or as top-level
+functions in their own file if they are reusable or big enough to warrant their own file.
+
+They usually have an optional Modifier argument (by convention it's usually the first optional argument) used to
+configure the component (primarily its position).
+Custom containers will also have an optional `block: LayoutScope.() -> Unit` argument (usually the final argument, so
+it is eligible as the DSL-like trailing lambda) that configures the children.
+
+It is of course also possible for a function component to add multiple children in the passed scope, however this should
+be used with care because the relative position / spacing of these children is not usually defined by the caller and so
+the function by itself is ambiguous. And similarly the caller might expect the function to define a single child and by
+then surprised that it throws off things because there's suddenly more children than expected.
+So the only time this functionality may be useful is in local helper functions that are defined very close to their
+usage (acting more like a template than a function component at that point); though even in these cases, often it makes
+sense to add a wrapper container in the function component anyway.
+
+##### Class components
+
+Sometimes you need your custom component to be a full blown, regular Elementa component class.
+But you can still use the Layout DSL to configure the inner working of such components:
+
+```kotlin
+class Button(label: String, onClick: () -> Unit) : UIContainer() {
+ init {
+ layout(Modifier.width(100f).height(20f).color(Palette.buttonOutlineColor).onLeftClick(onClick)) {
+ box(Modifier.alignBoth(Alignment.Center).fillParent(padding = 1f).color(Palette.buttonBackgroundColor)) {
+ text(label)
+ }
+ }
+ }
+}
+
+window.layout {
+ column(Arrangement.spacedBy(5f)) {
+ row(Arrangement.spacedBy(3f)) {
+ Button("Yes", ::accept)()
+ Button("No", ::reject)()
+ }
+ Button("Cancel", ::cancel)(Modifier.width(30f).height(10f))
+ }
+}
+```
+
+The main disadvantage here is that your custom component can no longer be a `box`/`row`/`column`, you need to deal with
+positioning of your immediate children manually. And, if your component is sized bottom-up, you also need to deal with
+the sizing of it manually.
+
+### Arrangement
+
+`Arrangement` provides a way to declare how multiple components should be arranged (i.e. where surplus space goes) on
+a particular axis.
+
+In terms old regular Elementa, it provides position constraints (for one axis) for all children of a given container and
+centrally decides where all components will go.
+
+Note: A single `Arrangement` cannot currently be shared between multiple rows/columns; this should be fixed at some
+ point, because `Alignment` and `Modifier` do allow for this (and even explicitly encourage it).
+
+Suppose we have a row with three equally sized children and 8 pixels of surplus space:
+```kotlin
+row(Modifier.width(38), arrangementGoesHere) {
+ box(Modifier.width(10))
+ box(Modifier.width(10))
+ box(Modifier.width(10))
+}
+```
+
+#### SpacedAround
+
+Simply divides the available free space in two and places it on both sides of the children:
+```
+| |--------||--------||--------| |
+```
+
+#### SpacedBetween
+
+Divides up the available free space and places it between the children:
+```
+||--------| |--------| |--------||
+```
+
+#### SpacedEvenly
+
+Divides up the available free space and places it between and around the children:
+```
+| |--------| |--------| |--------| |
+```
+
+#### spacedBy
+
+Uses a given fixed `spacing` between the children and positions the entire block according to the given `float`:
+```
+Arrangement.spacedBy(1f, FloatPosition.Start)
+||--------| |--------| |--------| |
+Arrangement.spacedBy(1f, FloatPosition.Center)
+| |--------| |--------| |--------| |
+Arrangement.spacedBy(1f, FloatPosition.End)
+| |--------| |--------| |--------||
+```
+
+Unlike the previous arrangements, `spacedBy` is usually used for bottom-up layouts. If no explicit width is set on the
+row, its width will be the sum of the widths of its children plus the spacing between them:
+
+```kotlin
+row(Arrangement.spacedBy(1)) {
+ box(Modifier.width(10))
+ box(Modifier.width(10))
+ box(Modifier.width(10))
+}
+// Results in ||--------| |--------| |--------||
+```
+
+Note: Currently the default FloatPosition is `Start`, this may be changed to `Center` in the future.
+ Use of the floating parameter is actually quite rare because spacedBy in top-down layouts is quite rare and
+ because the same effect can be achieved by putting a box around a bottom-up spacedBy row and then simply
+ controlling the float of the entire row within that box.
+
+#### equalWeight
+
+Uses a given fixed `spacing` between the children and distributes remaining space **into** the children.
+That is, it overwrites the width of all its children and sets them all to the same width such that no surplus space
+remains.
+
+```
+Arrangement.equalWeight(1f)
+||----------| |----------| |----------||
+```
+
+Note how the children end up being 12 wide, not 10.
+But it can also shrink the children:
+
+```
+Arrangement.equalWeight(10f)
+||----| |----| |----||
+```
+
+### Dynamic content
+
+So far we have only built static component trees but quite frequently components will only be visible under certain
+circumstances (like when a certain State is true), usually this boils down calling `hide` and `show` on the component
+from the state change listener. But doing this correctly is actually deceptively hard (especially keeping the correct
+order between multiple conditional components).
+
+With Layout DSL, this is now possible and it's stupidly simple (at least to use; the implementation, not so much):
+```kotlin
+val myBoolState = mutableStateOf(true)
+window.layout {
+ text("Before")
+ if_(myBoolState) {
+ text("It's true!")
+ } `else` {
+ box(Modifier.color(Color.RED)) {
+ text("Oh no")
+ }
+ }
+ text("After")
+}
+```
+
+This will at first only evaluate one of the two inner blocks.
+When the value changes, then it'll then remove all children from that block and evaluate the other block.
+By default, if the value then changes again, it will have remembered the components of the original block and simple add
+them back after removing the ones from the other block.
+
+This is usually what you want because it makes switching back and forth fast at the usually small cost of keeping
+components for both in memory.
+If for some reason you do not want to keep the inactive components around, you can pass `cache = false` in the `if_`
+call to disable this caching. It will then re-evaluate the branches on each change.
+
+Note that without the cache, care must be taken to not create any memory leaks when using StateV1, as change listeners
+registered on StateV1 do not get cleaned up automatically until both the state and all its listeners are eligible for
+garbage collection.
+
+#### bind
+
+But what if you have more than just true and false?
+`bind` will accept any state, and re-evaluate the block whenever its value changes.
+
+Note that unlike with `if`, since there can theoretically be an unbounded amount of values, caching is disabled for
+`bind` by default. You can enable it via the optional parameter and probably should do so wherever it makes sense.
+
+```kotlin
+val myStrState = mutableStateOf("Test")
+window.layout {
+ bind(myStrState) { myStr ->
+ text("My string is $myStr")
+ }
+}
+```
+
+Because it is quite common, there is a specialized variant meant for states that can be null:
+```kotlin
+val myStrState = mutableStateOf(null)
+window.layout {
+ ifNotNull(myStrState) { myStr ->
+ text("My string is $myStr (and never null)")
+ }
+
+ // Effectively equivalent to:
+ bind(myStrState) { myStr ->
+ if (myStr == null) return@bind
+ text("My string is $myStr (and never null)")
+ }
+}
+```
+
+#### forEach
+
+But what if you want a variable number of components?
+`forEach` will accept a `ListState` and call the block for each `T`, disposing of the correct scopes when values are
+removed from the state and inserting new scopes at the right place as new values are added to the scope.
+
+Note that unlike with `if`, since there can theoretically be an unbounded amount of values, caching is disabled for
+`forEach` by default.
+You can enable it via the optional parameter and probably should do so wherever it makes sense. This is especially true
+if you have a practically limited amount of values but want to implement something like search where having to re-create
+all the components whenever you remove characters from your search term would be quite expensive.
+
+```kotlin
+val myListState = mutableListStateOf("a", "b", "c")
+window.layout {
+ forEach(myListState) { myStr ->
+ text(myStr)
+ }
+}
+```
+
+### Hovering
+
+Components will frequently change their looks when they are hovered.
+This is generally achieved with the `whenHovered` modifier.
+For many modifiers there also exist variants with the `hovered` prefix (e.g. `hoveredColor`) which are shortcuts for
+this modifier.
+
+```kotlin
+// A box that's red when hovered and black otherwise
+box(Modifier.whenHovered(Modifier.color(Color.RED), Modifier.color(Color.BLACK)).then(size))
+// or, same thing, a black box that turns red when hovered:
+box(Modifier.color(Color.Black).whenHovered(Modifier.color(Color.RED)).then(size))
+// or, same thing, with the `hovered`-prefixed `color` modifier
+box(Modifier.color(Color.Black).hoverColor(Color.RED).then(size))
+```
+
+A hover scope is **required** to use these (see next section).
+This is because aside from toy examples, you usually want one.
+
+#### Hover Scope
+
+Usually however, we don't actually care about whether any specific component, like the text of a button, is hovered.
+What we really care about is whether the button as a whole is hovered.
+And, if it is, then all children of the button should act as if they are hovered as well.
+
+Such a scope of elements (specifically a sub-tree of components), that should all act together with respect to hovering,
+is declared with the `hoverScope` modifier.
+
+If declared with default arguments on a component, the hover state of that container will be tracked, and all
+(direct and indirect) children as well as the component itself will follow that state for their `whenHovered` modifiers.
+
+If more control is required over when the hover state is true or false, the `hoverScope` modifier can optionally
+receive a `State` to use as the hover state.
+
+(TODO this currently uses StateV1, and as such may cause leaks if the children are highly dynamic; need to update to V2)
+
+Note: The `hoverScope` modifier should not be confused with the `UIComponent.hoverScope` extension function.
+ The former is used to declare a new hover scope while the latter is used to retrieve the hover scope applicable
+ to a component like `whenHovered` does.
+ It should also not be confused with the `UIComponent.hoverState` extension function, which is a lower-level
+ function commonly used prior to the introduction of hover scopes. It simply returns a State for whether that
+ specific component is hovered. That is what is used by the `hoverScope` modifier if you do not pass a custom
+ State.
+
+#### Default hover scope and inheritance
+
+There are standalone components which will usually want to be treated as a single hover scope, e.g. a button component
+will in the vast majority of cases be the root of a hover scope.
+To that end, they will usually apply the `hoverScope` modifier to themselves (or `makeHoverScope` for class components).
+
+But what if we want to disable hovering of such a component (assuming the component doesn't have a dedicated way to do
+that)?
+
+This is not much of a problem, calling `hoverScope` again on the same component will simply replace the default one
+installed by the component itself: `Button()(Modifier.hoverScope(BasicState(false)))`
+
+But what if you want to use such a component as part of a larger component where hovering anywhere on the larger
+component will affect that component as well?
+
+By default hover scopes are not inherited, meaning even though both scopes will show as hovered when you place your
+cursor in such a way that it is inside both, the same is not true when it is only over the larger one. In that case, by
+default, only the larger component will appear hovered.
+We can however override the hover scope of the inner component as above and simply pass the hover state of the outer
+component for it to use. The `inheritHoverScope` modifier when applied to the inner component does exactly that.
+
+## Style Guide
+
+This section list various code style rules related to the Layout DSL and surrounding mechanisms.
+Most of these are fuzzy and much less strict than general code style guidelines and should be considered recommendations
+rather than hard rules.
+Where possible, you should follow these as they aid in making the code easier to read for anyone used to seeing code
+that follows these rules, but if they worsen readability in some specific case, then you should not feel obliged to
+follow them just for the sake of it.
+
+This list is likely incomplete and should be expanded whenever we find us adhering to any yet unwritten rules.
+
+The guiding principle which most of these follow is to keep in mind the original purpose of the Layout DSL as explained
+in the "Motivation" section: Being able to understand the overall structure/layout of a GUI without having to run or
+laboriously mentally evaluate them.
+
+### Keep it short
+
+Within the DSL, keep the closing parenthesis on the same line as the respective opening parenthesis.
+If that makes the line too long, you're probably doing too much in there. Some of your options are:
+
+If you have a click handler or any other non-trivial lambda in there, move it to a function outside the Layout DSL.
+
+If your modifier chain is too long, remember that modifiers were meant to be re-usable, so there's usually nothing
+wrong with declaring a local variable with the modifier beforehand and then using that (potentially in multiple places).
+Do try to keep non-custom layout information (i.e. positioning and sizing modifiers) in-line though, as these are
+usually required to understand the layout, which is the point of the DSL after all.
+Another exception to this is the `hoverScope` modifier due to it conceptually being more of a property of the entire
+sub-tree rather than any specific component.
+
+If you are deeply indented (or even if you are not yet), consider extracting out function components where it makes
+semantic sense.
+This is especially useful for things with click handler or other lambdas (like mapped states) as these can nicely be
+put at the start of the function component, where they're still close to their usage, just not too close.
+
+### Miscellaneous
+
+- Instead of `whenHovered`, prefer using the `hovered` variants and the regular variant where those exist,
+ e.g. `.color(regular).hoveredColor(hovered)`. Easier to read because the regular/non-hovered variant can go first.
+- When you need a `row` or `column` with non-standard arrangement but no special modifier, use the overload instead
+ of using a keyword argument to pass the arrangement. The keyword is quite long and standard arrangements are prefixed
+ by `Arrangement.` already.
+- Usually `align(Center)` is redundant. See the "Containers" section.
+- Avoid `onMouseEnter`/`onMouseLeave`/`whenMouseEntered`. These do not even handle occlusion properly.
+ See the "Hovering" section instead.
+- When order of modifiers does not matter semantically, prefer
+ - size before position before everything else, `hoverScope` last
+ - width before height, x before Y
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt
new file mode 100644
index 00000000..4fac8234
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/alignment.kt
@@ -0,0 +1,55 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.CenterConstraint
+import gg.essential.elementa.constraints.PositionConstraint
+import gg.essential.elementa.dsl.pixels
+import gg.essential.elementa.common.constraints.CenterPixelConstraint
+
+interface Alignment {
+ fun applyHorizontal(component: UIComponent): () -> Unit
+ fun applyVertical(component: UIComponent): () -> Unit
+
+ companion object {
+ @Suppress("FunctionName")
+ fun Start(padding: Float): Alignment = BasicAlignment { padding.pixels() }
+ @Suppress("FunctionName")
+ fun Center(roundUp: Boolean): Alignment = BasicAlignment { CenterPixelConstraint(roundUp) }
+ @Suppress("FunctionName")
+ fun End(padding: Float): Alignment = BasicAlignment { padding.pixels(alignOpposite = true) }
+
+ val Start: Alignment = Start(0f)
+ val Center: Alignment = BasicAlignment { CenterPixelConstraint() }
+ val End: Alignment = End(0f)
+
+ val TrueCenter: Alignment = BasicAlignment { CenterConstraint() }
+ }
+}
+
+private class BasicAlignment(private val constraintFactory: () -> PositionConstraint) : Alignment {
+ override fun applyHorizontal(component: UIComponent): () -> Unit {
+ return BasicXModifier(constraintFactory).applyToComponent(component)
+ }
+
+ override fun applyVertical(component: UIComponent): () -> Unit {
+ return BasicYModifier(constraintFactory).applyToComponent(component)
+ }
+}
+
+fun Modifier.alignBoth(alignment: Alignment) = alignHorizontal(alignment).alignVertical(alignment)
+
+fun Modifier.alignHorizontal(alignment: Alignment) = this then HorizontalAlignmentModifier(alignment)
+
+fun Modifier.alignVertical(alignment: Alignment) = this then VerticalAlignmentModifier(alignment)
+
+private class HorizontalAlignmentModifier(private val alignment: Alignment) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ return alignment.applyHorizontal(component)
+ }
+}
+
+private class VerticalAlignmentModifier(private val alignment: Alignment) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ return alignment.applyVertical(component)
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt
new file mode 100644
index 00000000..bd46202c
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/arrangement.kt
@@ -0,0 +1,270 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.*
+import gg.essential.elementa.constraints.resolution.ConstraintVisitor
+import gg.essential.elementa.utils.ObservableAddEvent
+import gg.essential.elementa.utils.ObservableClearEvent
+import gg.essential.elementa.utils.ObservableListEvent
+import gg.essential.elementa.utils.ObservableRemoveEvent
+import gg.essential.elementa.utils.roundToRealPixels
+
+interface Arrangement {
+ fun initialize(component: UIComponent, axis: Axis)
+
+ companion object {
+ val SpaceAround: Arrangement get() = SpaceAroundArrangement.Factory
+ val SpaceBetween: Arrangement get() = SpaceBetweenArrangement.Factory
+ val SpaceEvenly: Arrangement get() = SpaceEvenlyArrangement.Factory
+
+ fun spacedBy(): Arrangement = SpacedArrangement.DefaultFactory
+ fun spacedBy(spacing: Float = 0f, float: FloatPosition = FloatPosition.CENTER): Arrangement = SpacedArrangement.Factory(spacing, float)
+ fun equalWeight(spacing: Float = 0f): Arrangement = EqualWeightArrangement.Factory(spacing)
+ }
+}
+
+abstract class ArrangementInstance(
+ val mainAxis: Axis,
+) {
+ internal var recalculatePositions = true
+ internal var recalculateSizes = true
+
+ protected lateinit var boundComponent: UIComponent
+ private set
+ protected val lastPosValues = hashMapOf()
+ protected val lastSizeValues = hashMapOf()
+
+ abstract fun layoutPositions()
+ open fun layoutSizes() {}
+ abstract fun getPadding(child: UIComponent): Float
+
+ fun getPosValue(component: UIComponent): Float {
+ if (recalculatePositions) {
+ layoutPositions()
+ recalculatePositions = false
+ }
+ return lastPosValues[component]
+ ?: error("Component $component's position was not laid out by arrangement $this")
+ }
+
+ fun getSizeValue(component: UIComponent): Float {
+ if (recalculateSizes) {
+ layoutSizes()
+ recalculateSizes = false
+ }
+ return lastSizeValues[component]
+ ?: error("Component $component's size was not laid out by arrangement $this")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ open fun initialize(component: UIComponent) {
+ boundComponent = component
+ component.children.forEach(::conformChild)
+ component.children.addObserver { _, arg ->
+ when (val event = arg as? ObservableListEvent ?: return@addObserver) {
+ is ObservableAddEvent -> conformChild(event.element.value)
+ is ObservableRemoveEvent -> {
+ lastPosValues.remove(event.element.value)
+ lastSizeValues.remove(event.element.value)
+ }
+ is ObservableClearEvent -> {
+ lastPosValues.clear()
+ lastSizeValues.clear()
+ }
+ }
+ }
+ }
+
+ open fun conformChild(child: UIComponent) {
+ when (mainAxis) {
+ Axis.HORIZONTAL -> child.setX(ArrangementControlledPositionConstraint(this))
+ Axis.VERTICAL -> child.setY(ArrangementControlledPositionConstraint(this))
+ }
+ }
+
+ protected fun UIComponent.getMainAxisSize() = when (mainAxis) {
+ Axis.HORIZONTAL -> getWidth()
+ Axis.VERTICAL -> getHeight()
+ }
+
+ protected fun UIComponent.getCrossAxisSize() = when (mainAxis) {
+ Axis.HORIZONTAL -> getHeight()
+ Axis.VERTICAL -> getWidth()
+ }
+
+ protected fun UIComponent.getMainAxisStart() = when (mainAxis) {
+ Axis.HORIZONTAL -> getLeft()
+ Axis.VERTICAL -> getTop()
+ }
+
+ protected fun UIComponent.getCrossAxisStart() = when (mainAxis) {
+ Axis.HORIZONTAL -> getTop()
+ Axis.VERTICAL -> getLeft()
+ }
+}
+
+private open class SpacedArrangement(
+ axis: Axis,
+ protected val spacing: Float = 0f,
+ protected val floatPosition: FloatPosition = FloatPosition.CENTER,
+) : ArrangementInstance(axis) {
+ open fun getSpacing(parent: UIComponent) = spacing
+
+ open fun getStartOffset(parent: UIComponent, spacing: Float): Float {
+ val childrenSize = parent.children.sumOf { it.getMainAxisSize() } + spacing * (parent.children.size - 1)
+ return when (floatPosition) {
+ FloatPosition.START -> 0f
+ FloatPosition.CENTER -> parent.getMainAxisSize() / 2 - childrenSize / 2
+ FloatPosition.END -> parent.getMainAxisSize() - childrenSize
+ }
+ }
+
+ override fun layoutPositions() {
+ val spacing = getSpacing(boundComponent).roundToRealPixels()
+ var nextStart = boundComponent.getMainAxisStart() + getStartOffset(boundComponent, spacing).roundToRealPixels()
+ boundComponent.children.forEach {
+ lastPosValues[it] = nextStart
+ nextStart += it.getMainAxisSize() + spacing
+ }
+ }
+
+ override fun getPadding(child: UIComponent): Float {
+ return if (child === boundComponent.children.last()) 0f else getSpacing(boundComponent).roundToRealPixels()
+ }
+
+ data class Factory(val spacing: Float, val floatPosition: FloatPosition) : Arrangement {
+ override fun initialize(component: UIComponent, axis: Axis) {
+ SpacedArrangement(axis, spacing, floatPosition).initialize(component)
+ }
+ }
+
+ object DefaultFactory : Arrangement by Factory(0f, FloatPosition.CENTER)
+}
+
+private class SpaceBetweenArrangement(axis: Axis) : SpacedArrangement(axis) {
+ override fun getSpacing(parent: UIComponent): Float {
+ return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size - 1)
+ }
+
+ object Factory : Arrangement {
+ override fun initialize(component: UIComponent, axis: Axis) {
+ SpaceBetweenArrangement(axis).initialize(component)
+ }
+ }
+}
+
+private class SpaceEvenlyArrangement(axis: Axis) : SpacedArrangement(axis) {
+ override fun getSpacing(parent: UIComponent): Float {
+ return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / (parent.children.size + 1)
+ }
+
+ override fun getStartOffset(parent: UIComponent, spacing: Float): Float {
+ return spacing
+ }
+
+ object Factory : Arrangement {
+ override fun initialize(component: UIComponent, axis: Axis) {
+ SpaceEvenlyArrangement(axis).initialize(component)
+ }
+ }
+}
+
+private class SpaceAroundArrangement(axis: Axis) : SpacedArrangement(axis) {
+ override fun getSpacing(parent: UIComponent): Float {
+ return (parent.getMainAxisSize() - parent.children.sumOf { it.getMainAxisSize() }) / parent.children.size
+ }
+
+ override fun getStartOffset(parent: UIComponent, spacing: Float): Float {
+ return spacing / 2
+ }
+
+ object Factory : Arrangement {
+ override fun initialize(component: UIComponent, axis: Axis) {
+ SpaceAroundArrangement(axis).initialize(component)
+ }
+ }
+}
+
+private class EqualWeightArrangement(axis: Axis, spacing: Float) : SpacedArrangement(axis, spacing, FloatPosition.CENTER) {
+ override fun conformChild(child: UIComponent) {
+ super.conformChild(child)
+ when (mainAxis) {
+ Axis.HORIZONTAL -> child.setWidth(ArrangementControlledSizeConstraint(this))
+ Axis.VERTICAL -> child.setHeight(ArrangementControlledSizeConstraint(this))
+ }
+ }
+
+ override fun layoutSizes() {
+ val childCount = boundComponent.children.size
+ val childSize = (boundComponent.getMainAxisSize() - (childCount - 1) * spacing) / childCount
+ boundComponent.children.forEach {
+ lastSizeValues[it] = childSize
+ }
+ }
+
+ data class Factory(val spacing: Float) : Arrangement {
+ override fun initialize(component: UIComponent, axis: Axis) {
+ EqualWeightArrangement(axis, spacing).initialize(component)
+ }
+ }
+}
+
+private class ArrangementControlledPositionConstraint(private val arrangement: ArrangementInstance) : PositionConstraint, PaddingConstraint {
+ override var cachedValue = 0f
+ override var recalculate = true
+ set(value) {
+ field = value
+ if (value) {
+ arrangement.recalculatePositions = true
+ }
+ }
+ override var constrainTo: UIComponent?
+ get() = null
+ set(_) = error("Cannot bind an arrangement-controlled constraint to another component!")
+
+ init {
+ arrangement.recalculatePositions = true
+ }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {
+ }
+
+ override fun getXPositionImpl(component: UIComponent) = arrangement.getPosValue(component)
+
+ override fun getYPositionImpl(component: UIComponent) = arrangement.getPosValue(component)
+
+ override fun getHorizontalPadding(component: UIComponent): Float {
+ return if (arrangement.mainAxis == Axis.HORIZONTAL) arrangement.getPadding(component) else 0f
+ }
+
+ override fun getVerticalPadding(component: UIComponent): Float {
+ return if (arrangement.mainAxis == Axis.VERTICAL) arrangement.getPadding(component) else 0f
+ }
+}
+
+private class ArrangementControlledSizeConstraint(private val arrangement: ArrangementInstance) : SizeConstraint {
+ override var cachedValue = 0f
+ override var recalculate = true
+ set(value) {
+ field = value
+ if (value) {
+ arrangement.recalculateSizes = true
+ }
+ }
+ override var constrainTo: UIComponent?
+ get() = null
+ set(_) = error("Cannot bind an arrangement-controlled constraint to another component!")
+
+ init {
+ arrangement.recalculateSizes = true
+ }
+
+ override fun visitImpl(visitor: ConstraintVisitor, type: ConstraintType) {
+ }
+
+ override fun getWidthImpl(component: UIComponent) = arrangement.getSizeValue(component)
+
+ override fun getHeightImpl(component: UIComponent) = arrangement.getSizeValue(component)
+
+ override fun getRadiusImpl(component: UIComponent) = arrangement.getSizeValue(component)
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt
new file mode 100644
index 00000000..81ba74c4
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/axis.kt
@@ -0,0 +1,6 @@
+package gg.essential.elementa.layoutdsl
+
+enum class Axis {
+ HORIZONTAL,
+ VERTICAL
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt
new file mode 100644
index 00000000..511a8acd
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/basicModifiers.kt
@@ -0,0 +1,62 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.*
+
+infix fun Modifier.then(other: UIComponent.() -> () -> Unit) = this then BasicModifier(other)
+
+private class BasicModifier(private val setup: UIComponent.() -> () -> Unit) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ return component.setup()
+ }
+}
+
+class BasicXModifier(private val constraint: () -> XConstraint) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldX = component.constraints.x
+ component.setX(constraint())
+ return {
+ component.setX(oldX)
+ }
+ }
+}
+
+class BasicYModifier(private val constraint: () -> YConstraint) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldY = component.constraints.y
+ component.setY(constraint())
+ return {
+ component.setY(oldY)
+ }
+ }
+}
+
+class BasicWidthModifier(private val constraint: () -> WidthConstraint) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldWidth = component.constraints.width
+ component.setWidth(constraint())
+ return {
+ component.setWidth(oldWidth)
+ }
+ }
+}
+
+class BasicHeightModifier(private val constraint: () -> HeightConstraint) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldHeight = component.constraints.height
+ component.setHeight(constraint())
+ return {
+ component.setHeight(oldHeight)
+ }
+ }
+}
+
+class BasicColorModifier(private val constraint: () -> ColorConstraint) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldColor = component.constraints.color
+ component.setColor(constraint())
+ return {
+ component.setColor(oldColor)
+ }
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt
new file mode 100644
index 00000000..14025179
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/color.kt
@@ -0,0 +1,62 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.ColorConstraint
+import gg.essential.elementa.constraints.animation.Animations
+import gg.essential.elementa.dsl.animate
+import gg.essential.elementa.dsl.toConstraint
+import gg.essential.elementa.state.State
+import gg.essential.elementa.state.toConstraint
+import gg.essential.elementa.state.v2.color.toConstraint
+import gg.essential.elementa.state.v2.effect
+import gg.essential.elementa.state.v2.stateOf
+import gg.essential.elementa.state.v2.toV2
+import gg.essential.elementa.util.hasWindow
+import java.awt.Color
+import gg.essential.elementa.state.v2.State as StateV2
+
+fun Modifier.color(color: Color) = this then BasicColorModifier { color.toConstraint() }
+
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead")
+fun Modifier.color(color: State) = this then BasicColorModifier { color.toConstraint() }
+
+fun Modifier.color(color: StateV2) = this then BasicColorModifier { color.toConstraint() }
+
+fun Modifier.hoverColor(color: Color, duration: Float = 0f) = hoverColor(stateOf(color), duration)
+
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead")
+fun Modifier.hoverColor(color: State, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration))
+
+fun Modifier.hoverColor(color: StateV2, duration: Float = 0f) = whenHovered(if (duration == 0f) Modifier.color(color) else Modifier.animateColor(color, duration))
+
+fun Modifier.animateColor(color: Color, duration: Float = .3f) = animateColor(stateOf(color), duration)
+
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead")
+fun Modifier.animateColor(color: State, duration: Float = .3f) = animateColor(color.toV2(), duration)
+
+fun Modifier.animateColor(color: StateV2, duration: Float = .3f) = this then AnimateColorModifier(color, duration)
+
+private class AnimateColorModifier(private val colorState: StateV2, private val duration: Float) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldColor = component.constraints.color
+
+ fun animate(color: ColorConstraint) {
+ if (component.hasWindow) {
+ component.animate {
+ setColorAnimation(Animations.OUT_EXP, duration, color)
+ }
+ } else {
+ component.setColor(color)
+ }
+ }
+
+ val removeListenerCallback = effect(component) {
+ animate(colorState().toConstraint())
+ }
+
+ return {
+ removeListenerCallback()
+ animate(oldColor)
+ }
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt
new file mode 100644
index 00000000..5662ccb4
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/containers.kt
@@ -0,0 +1,247 @@
+@file:OptIn(ExperimentalContracts::class)
+
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.components.ScrollComponent
+import gg.essential.elementa.components.UIBlock
+import gg.essential.elementa.components.Window
+import gg.essential.elementa.constraints.ChildBasedMaxSizeConstraint
+import gg.essential.elementa.constraints.ChildBasedSizeConstraint
+import gg.essential.elementa.constraints.WidthConstraint
+import gg.essential.elementa.dsl.boundTo
+import gg.essential.elementa.dsl.childOf
+import gg.essential.elementa.dsl.coerceAtLeast
+import gg.essential.elementa.dsl.percent
+import gg.essential.elementa.dsl.pixels
+import gg.essential.elementa.common.HollowUIContainer
+import gg.essential.elementa.common.constraints.AlternateConstraint
+import gg.essential.elementa.common.constraints.SpacedCramSiblingConstraint
+import gg.essential.elementa.state.v2.*
+import gg.essential.universal.UMatrixStack
+import java.awt.Color
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+fun LayoutScope.box(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit = {}): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+
+ val container = TransparentBlock().apply {
+ componentName = "BoxContainer"
+ setWidth(ChildBasedSizeConstraint())
+ setHeight(ChildBasedSizeConstraint())
+ }
+ container.addChildModifier(Modifier.alignHorizontal(Alignment.Center).alignVertical(Alignment.Center))
+ return container(modifier = modifier, block = block)
+}
+
+fun LayoutScope.row(horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ return row(Modifier, horizontalArrangement, verticalAlignment, block)
+}
+fun LayoutScope.row(modifier: Modifier, horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+
+ val rowContainer = TransparentBlock().apply {
+ componentName = "RowContainer"
+ setWidth(ChildBasedSizeConstraint())
+ setHeight(ChildBasedMaxSizeConstraint())
+ }
+
+ rowContainer.addChildModifier(Modifier.alignVertical(verticalAlignment))
+
+ rowContainer(modifier = modifier, block = block)
+ horizontalArrangement.initialize(rowContainer, Axis.HORIZONTAL)
+
+ return rowContainer
+}
+
+fun LayoutScope.column(verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ return column(Modifier, verticalArrangement, horizontalAlignment, block)
+}
+fun LayoutScope.column(modifier: Modifier, verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+
+ val columnContainer = TransparentBlock().apply {
+ componentName = "ColumnContainer"
+ setWidth(ChildBasedMaxSizeConstraint())
+ setHeight(ChildBasedSizeConstraint())
+ }
+
+ columnContainer.addChildModifier(Modifier.alignHorizontal(horizontalAlignment))
+
+ columnContainer(modifier = modifier, block = block)
+ verticalArrangement.initialize(columnContainer, Axis.VERTICAL)
+
+ return columnContainer
+}
+
+fun LayoutScope.flowContainer(
+ modifier: Modifier = Modifier,
+ // TODO ideally we can make this use Arrangement on a per-row basis, currently it's just always SpaceBetween
+ minSeparation: () -> WidthConstraint = { 0.pixels },
+ verticalSeparation: () -> WidthConstraint = { 0.pixels },
+ block: LayoutScope.() -> Unit = {},
+): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+
+ val flowContainer = TransparentBlock().apply {
+ componentName = "FlowContainer"
+ setHeight(ChildBasedSizeConstraint())
+ }
+
+ val childModifier = Modifier
+ .then(BasicXModifier { SpacedCramSiblingConstraint(minSeparation(), 0.pixels) })
+ .then(BasicYModifier { SpacedCramSiblingConstraint(minSeparation(), 0.pixels, verticalSeparation()) })
+ flowContainer.addChildModifier(childModifier)
+
+ flowContainer(modifier = modifier, block = block)
+
+ return flowContainer
+}
+
+fun LayoutScope.scrollable(
+ modifier: Modifier = Modifier,
+ horizontal: Boolean = false,
+ vertical: Boolean = false,
+ pixelsPerScroll: Float = 15f,
+ block: LayoutScope.() -> Unit = {},
+): ScrollComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+
+ if (!horizontal && !vertical) {
+ throw IllegalArgumentException("Either `horizontal` or `vertical` or both must be `true`.")
+ }
+
+ val outer = ScrollComponent(
+ horizontalScrollEnabled = horizontal,
+ verticalScrollEnabled = vertical,
+ pixelsPerScroll = pixelsPerScroll,
+ )
+ val inner = outer.children.first()
+ // Need an extra wrapper because ScrollComponent does stupid things which breaks padding in the inner component
+ val content = HollowUIContainer() childOf outer // actually adds to `inner` because ScrollComponent redirects it
+
+ outer.apply {
+ componentName = "scrollable"
+ setWidth(ChildBasedSizeConstraint() boundTo content)
+ setHeight(ChildBasedSizeConstraint() boundTo content)
+ }
+ inner.apply {
+ componentName = "scrollableInternal"
+ setWidth(100.percent boundTo content)
+ setHeight(100.percent boundTo content)
+ }
+ content.apply {
+ componentName = "scrollableContent"
+ setWidth(AlternateConstraint(ChildBasedSizeConstraint(), 100.percent boundTo outer).coerceAtLeast(AlternateConstraint(100.percent boundTo outer, 0.pixels)))
+ setHeight(AlternateConstraint(ChildBasedSizeConstraint(), 100.percent boundTo outer).coerceAtLeast(AlternateConstraint(100.percent boundTo outer, 0.pixels)))
+ addChildModifier(Modifier.alignBoth(Alignment.Center))
+ }
+
+ outer(modifier = modifier)
+
+ block(LayoutScope(content, this, content))
+
+ return outer
+}
+
+fun LayoutScope.floatingBox(
+ modifier: Modifier = Modifier,
+ floating: State = stateOf(true),
+ block: LayoutScope.() -> Unit = {},
+): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+
+ fun UIComponent.isMounted(): Boolean =
+ parent == this || (this in parent.children && parent.isMounted())
+
+ // Elementa's floating system is quite tricky to work with because components that are floating are added into a
+ // persistent list but will not automatically be removed from that list when they're removed from the component
+ // tree, and as such will continue to render.
+ // This class tries to work around that by canceling `draw` and automatically un-floating itself in such cases,
+ // as well as automatically adding itself back to the floating list when it is put back into the component tree.
+ class FloatableContainer : UIBlock(Color(0, 0, 0, 0)) {
+ val shouldBeFloating: Boolean
+ get() = floating.get()
+
+ // Keeps track of the current floating state because the parent field of the same name is private
+ @set:JvmName("setFloating_")
+ var isFloating: Boolean = false
+ set(value) {
+ if (field == value) return
+ field = value
+ setFloating(value)
+ }
+
+ override fun animationFrame() {
+ // animationFrame is called from the regular tree traversal, so it's safe to directly update the floating
+ // list from here
+ isFloating = shouldBeFloating
+
+ super.animationFrame()
+ }
+
+ override fun draw(matrixStack: UMatrixStack) {
+ // If we're no longer mounted in the component tree, we should no longer draw
+ if (!isMounted()) {
+ // and if we're still floating (likely the case because that'll be why we're still drawing), then
+ // we also need to un-float ourselves
+ if (isFloating) {
+ // since this is likely called from the code that iterates over the floating list to draw each
+ // component, modifying the floating list here would result in a CME, so we need to delay this.
+ Window.enqueueRenderOperation {
+ // Note: we must not assume that our shouldBe state hasn't changed since we scheduled this
+ isFloating = shouldBeFloating && isMounted()
+ }
+ }
+ return
+ }
+
+ // If we should be floating but aren't right now, then this isn't being called from the floating draw loop
+ // and it should be safe for us to immediately set us as floating.
+ // Doing so will add us to the floating draw loop and thereby allow us to draw later.
+ if (shouldBeFloating && !isFloating) {
+ isFloating = true
+ return
+ }
+
+ // If we should not be floating but are right now, then this is similar to the no-longer-mounted case above
+ // i.e. we want to un-float ourselves.
+ // Except we're still mounted so we do still want to draw the content (this means it'll be floating for one
+ // more frame than it's supposed to but there isn't anything we can really do about that because the regular
+ // draw loop has already concluded by this point).
+ if (!shouldBeFloating && isFloating) {
+ Window.enqueueRenderOperation { isFloating = shouldBeFloating }
+ super.draw(matrixStack)
+ return
+ }
+
+ // All as it should be, can just draw it
+ super.draw(matrixStack)
+ }
+ }
+
+ val container = FloatableContainer().apply {
+ componentName = "floatingBox"
+ setWidth(ChildBasedSizeConstraint())
+ setHeight(ChildBasedSizeConstraint())
+ }
+ container.addChildModifier(Modifier.alignBoth(Alignment.Center))
+ return container(modifier = modifier, block = block)
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt
new file mode 100644
index 00000000..f9a96080
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/events.kt
@@ -0,0 +1,55 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.events.UIClickEvent
+import gg.essential.elementa.util.*
+
+import gg.essential.elementa.state.State as StateV1
+import gg.essential.elementa.state.v2.State as StateV2
+
+inline fun Modifier.onLeftClick(crossinline callback: UIComponent.() -> Unit) = this then {
+ val listener: UIComponent.(event: UIClickEvent) -> Unit = {
+ if (it.mouseButton == 0) {
+ callback()
+ }
+ }
+ onMouseClick(listener)
+ return@then { mouseClickListeners.remove(listener) }
+}
+
+/** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */
+fun Modifier.hoverScope(state: StateV1? = null) =
+ then { makeHoverScope(state); { throw NotImplementedError() } }
+
+/** Declare this component and its children to be in a hover scope. See [makeHoverScope]. */
+fun Modifier.hoverScope(state: StateV2) =
+ then { makeHoverScope(state); { throw NotImplementedError() } }
+
+/**
+ * Replaces the existing hover scope declared on this component with one which simply inherits from the parent scope.
+ * Can effectively be used to remove a scope from an otherwise self-contained component to join it with other custom
+ * components surrounding it.
+ */
+fun Modifier.inheritHoverScope() =
+ then { makeHoverScope(hoverScope(parentOnly = true)); { throw NotImplementedError() } }
+
+/**
+ * Applies [hoverModifier] while the component is hovered, otherwise applies [noHoverModifier] (or nothing by default).
+ *
+ * Whether a component is considered "hovered" depends solely on whether its [hoverScope] says that it is.
+ * It is not necessarily related to whether the mouse cursor is on top of the component (e.g. the label of a button may
+ * be considered hovered when the overall button is hovered, even when the cursor isn't on the text itself).
+ *
+ * A [Modifier.hoverScope] is **require** on the component or one of its parents.
+ */
+fun Modifier.whenHovered(hoverModifier: Modifier, noHoverModifier: Modifier = Modifier): Modifier =
+ then { Modifier.whenTrue(hoverScopeV2(), hoverModifier, noHoverModifier).applyToComponent(this) }
+
+/**
+ * Provides the [hoverScope] to be evaluated in a lambda which returns a modifier
+ */
+fun Modifier.withHoverState(func: (StateV2) -> Modifier) =
+ then { func(hoverScopeV2()).applyToComponent(this) }
+
+/** Applies a Tag to this component. See [UIComponent.addTag]. */
+fun Modifier.tag(tag: Tag) = then { addTag(tag); { removeTag(tag) } }
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt
new file mode 100644
index 00000000..d257d5e6
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/float.kt
@@ -0,0 +1,7 @@
+package gg.essential.elementa.layoutdsl
+
+enum class FloatPosition {
+ START,
+ CENTER,
+ END
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt
new file mode 100644
index 00000000..887c7a5e
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/gradient.kt
@@ -0,0 +1,32 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.effects.GradientEffect
+import gg.essential.elementa.state.v2.State
+import gg.essential.elementa.state.v2.stateOf
+import java.awt.Color
+
+fun Modifier.gradient(top: Color, bottom: Color, _desc: GradientVertDesc = GradientDesc) = gradient(stateOf(top), stateOf(bottom), _desc)
+fun Modifier.gradient(left: Color, right: Color, _desc: GradientHorzDesc = GradientDesc) = gradient(stateOf(left), stateOf(right), _desc)
+
+fun Modifier.gradient(top: State, bottom: State, _desc: GradientVertDesc = GradientDesc) = gradient(top, top, bottom, bottom)
+fun Modifier.gradient(left: State, right: State, _desc: GradientHorzDesc = GradientDesc) = gradient(left, right, left, right)
+
+sealed interface GradientVertDesc
+sealed interface GradientHorzDesc
+private object GradientDesc : GradientVertDesc, GradientHorzDesc
+
+fun Modifier.gradient(
+ topLeft: State,
+ topRight: State,
+ bottomLeft: State,
+ bottomRight: State,
+) = effect { GradientEffect(topLeft, topRight, bottomLeft, bottomRight) }
+
+private fun Modifier.effect(effect: () -> Effect) = this then {
+ val instance = effect()
+ enableEffect(instance)
+ return@then {
+ removeEffect(instance)
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt
new file mode 100644
index 00000000..4c13b78d
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/layout.kt
@@ -0,0 +1,380 @@
+@file:OptIn(ExperimentalContracts::class)
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.state.State
+import gg.essential.elementa.state.v2.ReferenceHolder
+import gg.essential.elementa.common.ListState
+import gg.essential.elementa.common.not
+import gg.essential.elementa.state.v2.*
+import gg.essential.elementa.state.v2.collections.MutableTrackedList
+import gg.essential.elementa.state.v2.collections.TrackedList
+import gg.essential.elementa.state.v2.collections.trackedListOf
+import gg.essential.elementa.state.v2.combinators.map
+import gg.essential.elementa.state.v2.combinators.not
+import gg.essential.elementa.util.hoveredState
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+import gg.essential.elementa.state.v2.ListState as ListStateV2
+import gg.essential.elementa.state.v2.State as StateV2
+
+class LayoutScope(
+ private val component: UIComponent,
+ private val parentScope: LayoutScope?,
+ val stateScope: ReferenceHolder,
+) {
+ /**
+ * As the name says, don't use this unless you really have to.
+ */
+ val containerDontUseThisUnlessYouReallyHaveTo: UIComponent
+ get() = component
+
+ private val childrenScopes = mutableListOf()
+
+ operator fun T.invoke(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit = {}): T {
+ this@LayoutScope.component.getChildModifier().applyToComponent(this)
+ modifier.applyToComponent(this)
+
+ val childScope = LayoutScope(this, this@LayoutScope, this)
+ childrenScopes.add(childScope)
+
+ childScope.block()
+
+ val index = childScope.findNextIndexIn(component) ?: 0
+ component.insertChildAt(this, index)
+
+ return this
+ }
+
+ operator fun LayoutDslComponent.invoke(modifier: Modifier = Modifier) = layout(modifier)
+
+ @Deprecated("Use Modifier.hoverScope() and Modifier.whenHovered(), instead.")
+ fun hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true) = component.hoveredState(hitTest, layoutSafe)
+
+ @Suppress("FunctionName")
+ fun if_(state: State, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl {
+ return if_(state.toV2(), cache, block)
+ }
+
+ fun if_(state: StateV2, cache: Boolean = true, block: LayoutScope.() -> Unit): IfDsl {
+ forEach({ if (state()) trackedListOf(Unit) else trackedListOf() }, cache) { block() }
+ return IfDsl({ !state() }, cache)
+ }
+
+ fun ifNotNull(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl {
+ return ifNotNull(state.toV2(), cache, block)
+ }
+
+ fun ifNotNull(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl {
+ forEach({ state()?.let { trackedListOf(it) } ?: trackedListOf() }, cache) { block(it) }
+ return IfDsl({ state() == null }, true)
+ }
+
+ fun if_(condition: StateByScope.() -> Boolean, cache: Boolean = false, block: LayoutScope.() -> Unit): IfDsl {
+ return if_(stateBy(condition), cache, block)
+ }
+
+ fun ifNotNull(stateBlock: StateByScope.() -> T?, cache: Boolean = false, block: LayoutScope.(T) -> Unit): IfDsl {
+ return ifNotNull(stateBy(stateBlock), cache, block)
+ }
+
+ class IfDsl(internal val elseState: StateV2, internal var cache: Boolean)
+
+ infix fun IfDsl.`else`(block: LayoutScope.() -> Unit) {
+ if_(elseState, cache, block)
+ }
+
+ /** Makes available to the inner scope the value of the given [state]. */
+ fun bind(state: State, cache: Boolean = false, block: LayoutScope.(T) -> Unit) {
+ bind(state.toV2(), cache, block)
+ }
+
+ /** Makes available to the inner scope the value of the given [state]. */
+ fun bind(state: StateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) {
+ forEach({ trackedListOf(state()) }, cache) { block(it) }
+ }
+
+ /** Makes available to the inner scope the value derived from the given [stateBlock]. */
+ fun bind(stateBlock: StateByScope.() -> T, cache: Boolean = false, block: LayoutScope.(T) -> Unit) {
+ bind(stateBy(stateBlock), cache, block)
+ }
+
+ /**
+ * Repeats the inner block for each element in the given list state.
+ * If the list state changes, components from old scopes are removed and new scopes are created and initialized as
+ * required.
+ * Order relative to other components within the same [layout] call is kept automatically at all times.
+ *
+ * Note that given old scopes are discarded, care must be taken to not inadvertently leak child components, e.g. via
+ * listener subscriptions or other links that cannot be cleaned up automatically.
+ * If the space of possible [T] is very limited, [cache] may be set to `true` to retain old scopes after they are
+ * removed and to re-use them if their corresponding [T] value is re-introduced at a later time.
+ * This requires that [T] be usable as a key in a HashMap.
+ */
+ fun forEach(state: ListState, cache: Boolean = false, block: LayoutScope.(T) -> Unit) {
+ forEach(state.toV2().toListState(), cache, block)
+ }
+
+ /**
+ * StateV2 support for forEach
+ */
+ fun forEach(list: ListStateV2, cache: Boolean = false, block: LayoutScope.(T) -> Unit) {
+ val forEachScope = LayoutScope(component, this@LayoutScope, stateScope)
+ childrenScopes.add(forEachScope)
+
+ val cacheMap =
+ if (cache) mutableMapOf>()
+ else null
+ fun getCacheEntry(key: T) = cacheMap?.getOrPut(key) { mutableListOf() }
+
+ fun add(index: Int, element: T) {
+ val cachedScope = getCacheEntry(element)?.removeLastOrNull()
+ if (cachedScope != null) {
+ forEachScope.childrenScopes.add(index, cachedScope)
+ if (forEachScope.isVirtualScopeMounted()) {
+ cachedScope.remount()
+ }
+ } else {
+ // If the `forEach` is not cached, we give each child scope its own reference holder.
+ // This scope will be dropped once the child scope is removed.
+ val childStateScope = if (cache) forEachScope.stateScope else ReferenceHolderImpl()
+ val newScope = LayoutScope(component, forEachScope, childStateScope)
+
+ forEachScope.childrenScopes.add(index, newScope)
+ newScope.block(element)
+ if (!forEachScope.isVirtualScopeMounted()) {
+ newScope.unmount()
+ }
+ }
+ }
+
+ fun remove(index: Int, element: T) {
+ val removedScope = forEachScope.childrenScopes.removeAt(index)
+ removedScope.unmount()
+ getCacheEntry(element)?.add(removedScope)
+ }
+
+ fun clear(elements: List) {
+ forEachScope.childrenScopes.forEachIndexed { index, layoutScope ->
+ layoutScope.unmount()
+ getCacheEntry(elements[index])?.add(layoutScope)
+ }
+ forEachScope.childrenScopes.clear()
+ }
+
+ fun update(change: TrackedList.Change) {
+ when (change) {
+ is TrackedList.Add -> {
+ val (index, element) = change.element
+ add(index, element)
+ }
+ is TrackedList.Remove -> {
+ val (index, element) = change.element
+ remove(index, element)
+ }
+ is TrackedList.Clear -> {
+ clear(change.oldElements)
+ }
+ }
+ }
+
+ var trackedList: TrackedList = MutableTrackedList()
+ effect(stateScope) {
+ val newList = list()
+ val oldList = trackedList
+ newList.getChangesSince(oldList).forEach { change -> update(change) }
+ trackedList = newList
+ }
+ }
+
+ /** Whether this scope is a virtual "forEach" scope. These share their target component with their parent scope. */
+ private fun isVirtual(): Boolean {
+ return parentScope?.component == component
+ }
+
+ /** Whether this virtual ("forEach") scope is presently (virtually) mounted inside its parent [component]. */
+ private fun isVirtualScopeMounted(): Boolean {
+ val parent = parentScope ?: return true // if we don't have a parent, we can only assume that we're mounted
+
+ // Check if this scope is currently mounted in its parent scope
+ if (this !in parent.childrenScopes) {
+ return false
+ }
+
+ // If the parent scope is a virtual scope as well, we can only be mounted if it is
+ if (parent.isVirtual()) {
+ return parent.isVirtualScopeMounted()
+ }
+
+ return true
+ }
+
+ /** Removes from [component] all components that where added within this scope. */
+ private fun unmount() {
+ for (childScope in childrenScopes) {
+ if (childScope.component == this.component) {
+ // This is a forEach scope, recurse down into its children
+ childScope.unmount()
+ } else {
+ component.removeChild(childScope.component)
+ }
+ }
+ }
+
+ /** Inverse of [unmount]. Re-adds to [component] all components that where added within this scope. */
+ private fun remount() {
+ for (childScope in childrenScopes) {
+ if (childScope.component == this.component) {
+ // This is a forEach scope, recurse down into its children
+ childScope.remount()
+ } else {
+ val index = childScope.findNextIndexIn(component) ?: 0
+ component.insertChildAt(childScope.component, index)
+ }
+ }
+ }
+
+ /**
+ * Finds the index in [parent]'s children at which a component should be inserted to end up right after [component].
+ * Works even when [component] is not currently present in [parent] by recursively searching the layout tree.
+ * If [parent] has no children in the layout tree, `null` is returned.
+ */
+ private fun findNextIndexIn(parent: UIComponent): Int? {
+ /** Searches this subtree for an index. */
+ fun LayoutScope.searchSubTree(range: IntProgression = childrenScopes.indices.reversed()): Int? {
+ if (component == parent) {
+ // This is a node in the subtree belonging to [parent] (e.g. the main scope, or a forEach scope),
+ // so we recursively search the children
+ for (index in range) {
+ childrenScopes[index].searchSubTree()
+ ?.let { return it }
+ }
+ return null
+ } else {
+ // Check if this child is currently present within its parent
+ return parent.children.indexOf(component).takeIf { it != -1 }
+ }
+ }
+
+ /** Searches by recursively traversing upwards the tree if no index can be found in this subtree. */
+ fun LayoutScope.search(beforeScope: LayoutScope): Int? {
+ val beforeIndex = childrenScopes.indexOf(beforeScope)
+
+ // Check all preceding siblings
+ searchSubTree((0 until beforeIndex).reversed())
+ ?.let { return it }
+
+ // If we can't find anything there, check the siblings one level up, recursively
+ val parentScope = parentScope ?: return null
+ // Though once we've found a scope that targets [parent], then we can stop ascending if we find a scope
+ // that doesn't target [parent] (i.e. one for parent's parent) because we only want to search all scopes
+ // targeting [parent].
+ if (component == parent && parentScope.component != parent) {
+ return null
+ }
+ return parentScope.search(this)
+ }
+
+ return parentScope?.search(this)?.let { it + 1 }
+ }
+}
+
+/**
+ * Runs [block] to lay out children of `this` component.
+ *
+ * The passed [modifier], if any, is applied to `this` component.
+ *
+ * Note: This does **not** change the constraints of `this`. These must be set up manually or via the passed [modifier].
+ *
+ * Note: Direct children of `this` will by default be top-left aligned as with all plain Elementa components.
+ * Consider using one of [layoutAsBox], [layoutAsRow], or [layoutAsColumn] instead to get the default center alignment
+ * that is typical for Layout DSL.
+ */
+inline fun UIComponent.layout(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ modifier.applyToComponent(this)
+ LayoutScope(this, null, this).block()
+}
+
+/**
+ * Runs [block] to lay out children of `this` component as if it was a [box].
+ *
+ * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier].
+ */
+fun UIComponent.layoutAsBox(modifier: Modifier = Modifier, block: LayoutScope.() -> Unit) {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ addChildModifier(Modifier.alignBoth(Alignment.Center))
+ layout(modifier, block)
+}
+
+/**
+ * Runs [block] to lay out children of `this` component as if it was a [row].
+ *
+ * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier].
+ * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedWidth].
+ * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedMaxHeight].
+ */
+fun UIComponent.layoutAsRow(modifier: Modifier, horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ addChildModifier(Modifier.alignVertical(verticalAlignment))
+ layout(modifier, block)
+ horizontalArrangement.initialize(this, Axis.HORIZONTAL)
+ return this
+}
+
+/**
+ * Runs [block] to lay out children of `this` component as if it was a [column].
+ *
+ * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier].
+ * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedMaxWidth].
+ * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedHeight].
+ */
+fun UIComponent.layoutAsColumn(modifier: Modifier, verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ addChildModifier(Modifier.alignHorizontal(horizontalAlignment))
+ layout(modifier, block)
+ verticalArrangement.initialize(this, Axis.VERTICAL)
+ return this
+}
+
+// Overloads without Modifier argument
+/**
+ * Runs [block] to lay out children of `this` component as if it was a [row].
+ *
+ * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier].
+ * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedWidth].
+ * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedMaxHeight].
+ */
+fun UIComponent.layoutAsRow(horizontalArrangement: Arrangement = Arrangement.spacedBy(), verticalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ return layoutAsRow(Modifier, horizontalArrangement, verticalAlignment, block)
+}
+/**
+ * Runs [block] to lay out children of `this` component as if it was a [column].
+ *
+ * Note: This does **not** change the size constrains of `this`. These must be set up manually or via [modifier].
+ * For the width, one would typically use [Modifier.fillWidth] or [Modifier.childBasedMaxWidth].
+ * For the height, one would typically use [Modifier.fillHeight] or [Modifier.childBasedHeight].
+ */
+fun UIComponent.layoutAsColumn(verticalArrangement: Arrangement = Arrangement.spacedBy(), horizontalAlignment: Alignment = Alignment.Center, block: LayoutScope.() -> Unit): UIComponent {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ return layoutAsColumn(Modifier, verticalArrangement, horizontalAlignment, block)
+}
+
+
+interface LayoutDslComponent {
+ fun LayoutScope.layout(modifier: Modifier = Modifier)
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt
new file mode 100644
index 00000000..b38e768c
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/lazy.kt
@@ -0,0 +1,35 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.components.UIContainer
+import gg.essential.elementa.components.Window
+import gg.essential.elementa.state.v2.MutableState
+import gg.essential.elementa.state.v2.mutableStateOf
+import gg.essential.universal.UMatrixStack
+
+/**
+ * Lazily initializes the inner scope by first only placing a [box] as described by [modifier] without any children and
+ * only initializing the inner scope once that box has been rendered once.
+ *
+ * This should be a last reserve for initializing a large list of poorly optimized components, not a common shortcut to
+ * "make it not lag". Properly profiling and fixing initialization performance issues should always be preferred.
+ */
+fun LayoutScope.lazyBox(modifier: Modifier = Modifier.fillParent(), block: LayoutScope.() -> Unit) {
+ val initialized = mutableStateOf(false)
+ box(modifier) {
+ if_(initialized, cache = false /** don't need it; once initialized, we are never going back */) {
+ block()
+ } `else` {
+ LazyComponent(initialized)(Modifier.fillParent())
+ }
+ }
+}
+
+private class LazyComponent(private val initialized: MutableState) : UIContainer() {
+ override fun draw(matrixStack: UMatrixStack) {
+ super.draw(matrixStack)
+
+ Window.enqueueRenderOperation {
+ initialized.set(true)
+ }
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt
new file mode 100644
index 00000000..13245b9b
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/modifier.kt
@@ -0,0 +1,32 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+
+interface Modifier {
+ /**
+ * Applies this modifier to the given component, and returns a function which can be called to undo the applied changes.
+ */
+ fun applyToComponent(component: UIComponent): () -> Unit
+
+ infix fun then(other: Modifier) = if (other === Modifier) this else CombinedModifier(this, other)
+
+ companion object : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit = {}
+
+ override infix fun then(other: Modifier) = other
+ }
+}
+
+private class CombinedModifier(
+ private val first: Modifier,
+ private val second: Modifier
+) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val undoFirst = first.applyToComponent(component)
+ val undoSecond = second.applyToComponent(component)
+ return {
+ undoSecond()
+ undoFirst()
+ }
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt
new file mode 100644
index 00000000..74d882b1
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/size.kt
@@ -0,0 +1,153 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.constraints.*
+import gg.essential.elementa.constraints.animation.*
+import gg.essential.elementa.dsl.*
+import gg.essential.elementa.common.constraints.FillConstraintIncludingPadding
+import gg.essential.elementa.state.v2.State
+import gg.essential.elementa.state.v2.effect
+import gg.essential.elementa.state.v2.stateOf
+import gg.essential.elementa.util.hasWindow
+
+fun Modifier.fillParent(fraction: Float = 1f, padding: Float = 0f) =
+ fillWidth(fraction, padding).fillHeight(fraction, padding)
+
+/** Fills [fraction] of parent width minus [leftPadding] and aligns [leftPadding] pixels from the left */
+fun Modifier.fillWidth(fraction: Float = 1f, leftPadding: Float, _desc: Int = 0) =
+ fillWidth(fraction, leftPadding, false).alignHorizontal(Alignment.Start(leftPadding))
+
+/** Fills [fraction] of parent width minus [rightPadding] and aligns [rightPadding] pixels from the right */
+fun Modifier.fillWidth(fraction: Float = 1f, rightPadding: Float, _desc: Short = 0) =
+ fillWidth(fraction, rightPadding, false).alignHorizontal(Alignment.End(rightPadding))
+
+/** Fills [fraction] of parent width minus [padding] from both sides */
+fun Modifier.fillWidth(fraction: Float = 1f, padding: Float = 0f) = fillWidth(fraction, padding, true)
+
+private fun Modifier.fillWidth(fraction: Float, padding: Float, doublePadding: Boolean) =
+ this then BasicWidthModifier { RelativeConstraint(fraction) - padding.pixels() * if (doublePadding) 2 else 1 }
+
+/** Fills [fraction] of parent height minus [topPadding] and aligns [topPadding] pixels from the top */
+fun Modifier.fillHeight(fraction: Float = 1f, topPadding: Float, _desc: Int = 0) =
+ fillHeight(fraction, topPadding, false).alignVertical(Alignment.Start(topPadding))
+
+/** Fills [fraction] of parent height minus [bottomPadding] and aligns [bottomPadding] pixels from the bottom */
+fun Modifier.fillHeight(fraction: Float = 1f, bottomPadding: Float, _desc: Short = 0) =
+ fillHeight(fraction, bottomPadding, false).alignVertical(Alignment.End(bottomPadding))
+
+/** Fills [fraction] of parent height minus [padding] from both sides */
+fun Modifier.fillHeight(fraction: Float = 1f, padding: Float = 0f) = fillHeight(fraction, padding, true)
+
+private fun Modifier.fillHeight(fraction: Float, padding: Float, doublePadding: Boolean) =
+ this then BasicHeightModifier { RelativeConstraint(fraction) - padding.pixels() * if (doublePadding) 2 else 1 }
+
+fun Modifier.childBasedSize(padding: Float = 0f) = childBasedWidth(padding).childBasedHeight(padding)
+
+fun Modifier.childBasedWidth(padding: Float = 0f) = this then BasicWidthModifier { ChildBasedSizeConstraint() + (padding.pixels * 2) }
+
+fun Modifier.childBasedHeight(padding: Float = 0f) = this then BasicHeightModifier { ChildBasedSizeConstraint() + (padding.pixels * 2) }
+
+fun Modifier.childBasedMaxSize(padding: Float = 0f) = childBasedMaxWidth(padding).childBasedMaxHeight(padding)
+
+fun Modifier.childBasedMaxWidth(padding: Float = 0f) = this then BasicWidthModifier { ChildBasedMaxSizeConstraint() + (padding.pixels * 2) }
+
+fun Modifier.childBasedMaxHeight(padding: Float = 0f) = this then BasicHeightModifier { ChildBasedMaxSizeConstraint() + (padding.pixels * 2) }
+
+fun Modifier.fillRemainingWidth() = this then BasicWidthModifier { FillConstraintIncludingPadding(useSiblings = true) }
+
+fun Modifier.fillRemainingHeight() = this then BasicHeightModifier { FillConstraintIncludingPadding(useSiblings = true) }
+
+fun Modifier.width(width: Float) = this then BasicWidthModifier { width.pixels() }
+
+fun Modifier.height(height: Float) = this then BasicHeightModifier { height.pixels() }
+
+fun Modifier.width(other: UIComponent) = this then BasicWidthModifier { CopyConstraintFloat() boundTo other }
+
+fun Modifier.height(other: UIComponent) = this then BasicHeightModifier { CopyConstraintFloat() boundTo other }
+
+fun Modifier.widthAspect(aspect: Float) = this then BasicWidthModifier { AspectConstraint(aspect) }
+
+fun Modifier.heightAspect(aspect: Float) = this then BasicHeightModifier { AspectConstraint(aspect) }
+
+fun Modifier.animateWidth(width: Float, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = animateWidth(stateOf { width.pixels }, duration, strategy)
+
+fun Modifier.animateHeight(height: Float, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = animateHeight(stateOf { height.pixels }, duration, strategy)
+
+fun Modifier.animateWidth(width: State<() -> WidthConstraint>, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = this then AnimateWidthModifier(width, duration, strategy)
+
+fun Modifier.animateHeight(height: State<() -> HeightConstraint>, duration: Float, strategy: AnimationStrategy = Animations.OUT_EXP) = this then AnimateHeightModifier(height, duration, strategy)
+
+fun Modifier.maxWidth(width: Float) = this then MaxWidthModifier(width)
+
+fun Modifier.maxHeight(height: Float) = this then MaxHeightModifier(height)
+
+private class AnimateWidthModifier(private val newWidth: State<() -> WidthConstraint>, private val duration: Float, private val strategy: AnimationStrategy) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldWidth = component.constraints.width
+
+ fun animate(widthConstraint: WidthConstraint) {
+ if (component.hasWindow) {
+ component.animate {
+ setWidthAnimation(strategy, duration, widthConstraint)
+ }
+ } else {
+ component.setWidth(widthConstraint)
+ }
+ }
+
+ val removeListenerCallback = effect(component) {
+ animate(newWidth()())
+ }
+
+ return {
+ removeListenerCallback()
+ animate(oldWidth)
+ }
+ }
+}
+
+private class AnimateHeightModifier(private val newHeight: State<() -> HeightConstraint>, private val duration: Float, private val strategy: AnimationStrategy) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldHeight = component.constraints.height
+
+ fun animate(heightConstraint: HeightConstraint) {
+ if (component.hasWindow) {
+ component.animate {
+ setHeightAnimation(strategy, duration, heightConstraint)
+ }
+ } else {
+ component.setHeight(heightConstraint)
+ }
+ }
+
+ val removeListenerCallback = effect(component) {
+ animate(newHeight()())
+ }
+
+ return {
+ removeListenerCallback()
+ animate(oldHeight)
+ }
+ }
+}
+
+private class MaxWidthModifier(private val width: Float) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldWidth = component.constraints.width
+ component.setWidth(min(oldWidth, width.pixels))
+
+ return {
+ component.setWidth(oldWidth)
+ }
+ }
+}
+
+private class MaxHeightModifier(private val height: Float) : Modifier {
+ override fun applyToComponent(component: UIComponent): () -> Unit {
+ val oldHeight = component.constraints.height
+ component.setHeight(min(oldHeight, height.pixels))
+ return {
+ component.setHeight(oldHeight)
+ }
+ }
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt
new file mode 100644
index 00000000..93039702
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/state.kt
@@ -0,0 +1,51 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.state.State
+import gg.essential.elementa.common.onSetValueAndNow
+import gg.essential.elementa.state.v2.combinators.map
+import gg.essential.elementa.state.v2.effect
+import gg.essential.elementa.state.v2.toV2
+import gg.essential.elementa.state.v2.State as StateV2
+
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead")
+fun Modifier.then(state: State): Modifier {
+ return this then {
+ var reverse: (() -> Unit)? = null
+
+ val cleanupState = state.onSetValueAndNow {
+ reverse?.invoke()
+ reverse = it.applyToComponent(this)
+ };
+
+ {
+ cleanupState()
+ reverse?.invoke()
+ reverse = null
+ }
+ }
+}
+
+fun Modifier.then(state: StateV2): Modifier {
+ return this then component@{
+ var reverse: (() -> Unit)? = null
+
+ val cleanupState = effect(this) {
+ reverse?.invoke()
+ reverse = state().applyToComponent(this@component)
+ };
+
+ {
+ cleanupState()
+ reverse?.invoke()
+ reverse = null
+ }
+ }
+}
+
+@Suppress("DeprecatedCallableAddReplaceWith")
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead")
+fun Modifier.whenTrue(state: State, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier =
+ then(state.toV2().map { if (it) activeModifier else inactiveModifier })
+
+fun Modifier.whenTrue(state: StateV2, activeModifier: Modifier, inactiveModifier: Modifier = Modifier): Modifier =
+ then(state.map { if (it) activeModifier else inactiveModifier })
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt
new file mode 100644
index 00000000..30bacb36
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/layoutdsl/util.kt
@@ -0,0 +1,47 @@
+package gg.essential.elementa.layoutdsl
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.components.UIBlock
+import gg.essential.elementa.dsl.boundTo
+import gg.essential.elementa.dsl.percent
+import gg.essential.elementa.dsl.pixels
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.common.Spacer
+import java.awt.Color
+
+@Suppress("FunctionName")
+fun TransparentBlock() = UIBlock(Color(0, 0, 0, 0))
+
+fun LayoutScope.spacer(width: Float, height: Float) = Spacer(width = width.pixels, height = height.pixels)()
+fun LayoutScope.spacer(width: Float, _desc: WidthDesc = Desc) = spacer(width, 0f)
+fun LayoutScope.spacer(height: Float, _desc: HeightDesc = Desc) = spacer(0f, height)
+fun LayoutScope.spacer(width: UIComponent, height: UIComponent) = Spacer(100.percent boundTo width, 100.percent boundTo height)()
+fun LayoutScope.spacer(width: UIComponent, _desc: WidthDesc = Desc) = Spacer(100.percent boundTo width, 0f.pixels)()
+fun LayoutScope.spacer(height: UIComponent, _desc: HeightDesc = Desc) = Spacer(0f.pixels, 100.percent boundTo height)()
+
+sealed interface WidthDesc
+sealed interface HeightDesc
+private object Desc : WidthDesc, HeightDesc
+
+// How is this not in the stdlib?
+internal inline fun Iterable.sumOf(selector: (T) -> Float): Float {
+ var sum = 0f
+ for (element in this) {
+ sum += selector(element)
+ }
+ return sum
+}
+
+fun UIComponent.getChildModifier() =
+ effects
+ .filterIsInstance()
+ .map { it.childModifier }
+ .reduceOrNull { acc, it -> acc then it }
+ ?: Modifier
+
+fun UIComponent.addChildModifier(modifier: Modifier) {
+ enableEffect(ChildModifierMarker(modifier))
+}
+
+// Serves as a marker only. FIXME: integrate directly into the component class when we transition this DSL to Elementa?
+private class ChildModifierMarker(val childModifier: Modifier) : Effect()
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt
new file mode 100644
index 00000000..9768289b
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeInTransition.kt
@@ -0,0 +1,41 @@
+package gg.essential.elementa.transitions
+
+import gg.essential.elementa.constraints.animation.AnimatingConstraints
+import gg.essential.elementa.constraints.animation.Animations
+import gg.essential.elementa.state.BasicState
+import gg.essential.elementa.transitions.BoundTransition
+import gg.essential.elementa.effects.AlphaEffect
+import kotlin.properties.Delegates
+
+/**
+ * Fades a component and all of its children in. This is done using
+ * [AlphaEffect]. When the transition is finished, the effect is removed.
+ */
+class FadeInTransition @JvmOverloads constructor(
+ private val time: Float = 1f,
+ private val animationType: Animations = Animations.OUT_EXP,
+) : BoundTransition() {
+
+ private val alphaState = BasicState(0f)
+ private var alpha by Delegates.observable(0f) { _, _, newValue ->
+ alphaState.set(newValue)
+ }
+
+ private val effect = AlphaEffect(alphaState)
+
+ override fun beforeTransition() {
+ boundComponent.enableEffect(effect)
+ }
+
+ override fun doTransition(constraints: AnimatingConstraints) {
+ constraints.setExtraDelay(time)
+ boundComponent.apply {
+ ::alpha.animate(animationType, time, 1f)
+ }
+ }
+
+ override fun afterTransition() {
+ boundComponent.removeEffect(effect)
+ effect.cleanup()
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt
new file mode 100644
index 00000000..38852d22
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/transitions/FadeOutTransition.kt
@@ -0,0 +1,42 @@
+package gg.essential.elementa.transitions
+
+import gg.essential.elementa.constraints.animation.AnimatingConstraints
+import gg.essential.elementa.constraints.animation.Animations
+import gg.essential.elementa.state.BasicState
+import gg.essential.elementa.transitions.BoundTransition
+import gg.essential.elementa.effects.AlphaEffect
+import kotlin.properties.Delegates
+
+/**
+ * Fades a component and all of its children out. This is done using
+ * [AlphaEffect]. When the transition is finished, the effect is removed.
+ * Typically, one would hide the component after this transition is finished.
+ */
+class FadeOutTransition @JvmOverloads constructor(
+ private val time: Float = 1f,
+ private val animationType: Animations = Animations.OUT_EXP,
+) : BoundTransition() {
+
+ private val alphaState = BasicState(1f)
+ private var alpha by Delegates.observable(1f) { _, _, newValue ->
+ alphaState.set(newValue)
+ }
+
+ private val effect = AlphaEffect(alphaState)
+
+ override fun beforeTransition() {
+ boundComponent.enableEffect(effect)
+ }
+
+ override fun doTransition(constraints: AnimatingConstraints) {
+ constraints.setExtraDelay(time)
+ boundComponent.apply {
+ ::alpha.animate(animationType, time, 0f)
+ }
+ }
+
+ override fun afterTransition() {
+ boundComponent.removeEffect(effect)
+ effect.cleanup()
+ }
+}
\ No newline at end of file
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt
new file mode 100644
index 00000000..25a8903e
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/elementaExtensions.kt
@@ -0,0 +1,444 @@
+package gg.essential.elementa.util
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.components.Window
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.state.BasicState
+import gg.essential.elementa.state.State
+import gg.essential.elementa.utils.ObservableAddEvent
+import gg.essential.elementa.utils.ObservableClearEvent
+import gg.essential.elementa.utils.ObservableList
+import gg.essential.elementa.utils.ObservableRemoveEvent
+import gg.essential.elementa.common.onSetValueAndNow
+import gg.essential.elementa.state.v2.*
+import gg.essential.elementa.state.v2.collections.MutableTrackedList
+import gg.essential.universal.UMouse
+import gg.essential.universal.UResolution
+import gg.essential.elementa.state.v2.ListState as ListStateV2
+import gg.essential.elementa.state.v2.State as StateV2
+
+val UIComponent.hasWindow: Boolean
+ get() = this is Window || hasParent && parent.hasWindow
+
+fun UIComponent.pollingState(initialValue: T? = null, getter: () -> T): State {
+ val state = BasicState(initialValue ?: getter())
+ enableEffect(object : Effect() {
+ override fun animationFrame() {
+ state.set(getter())
+ }
+ })
+ return state
+}
+
+fun UIComponent.pollingStateV2(initialValue: T? = null, getter: () -> T): StateV2 {
+ val state = mutableStateOf(initialValue ?: getter())
+ enableEffect(object : Effect() {
+ override fun animationFrame() {
+ state.set(getter())
+ }
+ })
+ return state
+}
+
+fun UIComponent.layoutSafePollingState(initialValue: T? = null, getter: () -> T): StateV2 {
+ val state = mutableStateOf(initialValue ?: getter())
+ enableEffect(object : Effect() {
+ override fun animationFrame() {
+ val window = Window.of(boundComponent)
+ // Start one-shot timer which will trigger immediately once the current `animationFrame` is complete
+ window.startTimer(0) { timerId ->
+ window.stopTimer(timerId)
+
+ state.set(getter())
+ }
+ }
+ })
+ return state
+}
+
+/**
+ * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this
+ * block via [StateScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any
+ * one of them changes.
+ */
+@Deprecated("Using StateV1 is discouraged, use StateV2 instead", ReplaceWith("stateBy", "gg.essential.elementa.state.v2.StateByKt.stateBy"))
+fun stateBy(block: StateScope.() -> T): State {
+ val subscribed = mutableMapOf, () -> Unit>()
+ val observed = mutableSetOf>()
+ val scope = object : StateScope {
+ override fun State.invoke(): T {
+ observed.add(this)
+ return get()
+ }
+ }
+
+ val result = BasicState(block(scope))
+
+ fun updateSubscriptions() {
+ observed.forEach { state ->
+ if (state !in subscribed) {
+ val unregister = state.onSetValue {
+ // FIXME this should really just run immediately but State is currently very prone to CME if you
+ // register or remove a listener while it its callback, so we need to delay here until that's fixed
+ Window.enqueueRenderOperation {
+ val newValue = block(scope)
+ updateSubscriptions()
+ result.set(newValue)
+ }
+ }
+ subscribed[state] = unregister
+ }
+ }
+
+ subscribed.entries.removeAll { (state, unregister) ->
+ if (state !in observed) {
+ unregister()
+ true
+ } else {
+ false
+ }
+ }
+
+ observed.clear()
+ }
+ updateSubscriptions()
+
+ return result
+}
+
+interface StateScope {
+ operator fun State.invoke(): T
+}
+
+/**
+ * Executes the supplied [block] on this component's animationFrame
+ */
+fun UIComponent.onAnimationFrame(block: () -> Unit) =
+ enableEffect(object : Effect() {
+ override fun animationFrame() {
+ block()
+ }
+ })
+
+/**
+ * Returns a state representing whether this UIComponent is hovered
+ *
+ * [hitTest] will perform a hit test to make sure the user is actually hovered over this component
+ * as compared to the mouse just being within its content bounds while being hovered over another
+ * component rendered above this.
+ *
+ * [layoutSafe] will delay the state change until a time in which it is safe to make layout changes.
+ * This option will induce an additional delay of one frame because the state is updated during the next
+ * [Window.enqueueRenderOperation] after the hoverState changes.
+ */
+fun UIComponent.hoveredStateV2(hitTest: Boolean = true, layoutSafe: Boolean = true): StateV2 {
+ // "Unsafe" means that it is not safe to depend on this for layout changes
+ val unsafeHovered = mutableStateOf(false)
+
+ // "Safe" because layout changes can directly happen when this changes (ie in onSetValue)
+ val safeHovered = mutableStateOf(false)
+
+ // Performs a hit test based on the current mouse x / y
+ fun hitTestHovered(): Boolean {
+ // Positions the mouse in the center of pixels so isPointInside will
+ // pass for items 1 pixel wide objects. See ElementaVersion v2 for more details
+ val halfPixel = 0.5f / UResolution.scaleFactor.toFloat()
+ val mouseX = UMouse.Scaled.x.toFloat() + halfPixel
+ val mouseY = UMouse.Scaled.y.toFloat() + halfPixel
+ return if (isPointInside(mouseX, mouseY)) {
+
+ val window = Window.of(this)
+ val hit = (window.hoveredFloatingComponent?.hitTest(mouseX, mouseY)) ?: window.hitTest(mouseX, mouseY)
+
+ hit.isComponentInParentChain(this) || hit == this
+ } else {
+ false
+ }
+ }
+
+ if (hitTest) {
+ // It's possible the animation framerate will exceed that of the actual frame rate
+ // Therefore, in order to avoid redundantly performing the hit test multiple times
+ // in the same frame, this boolean is used to ensure that hit testing is performed
+ // at most only a single time each frame
+ var registerHitTest = true
+
+ onAnimationFrame {
+ if (registerHitTest) {
+ registerHitTest = false
+ Window.enqueueRenderOperation {
+ // The next animation frame should register another renderOperation
+ registerHitTest = true
+
+ // It is possible that this component or a component in its parent tree
+ // was removed from the component tree between the last call to animationFrame
+ // and this evaluation in enqueueRenderOperation. If that is the case, we should not
+ // perform the hit test because it will throw an exception.
+ if (!this.isInComponentTree()) {
+ // Unset the hovered state because a component can no longer
+ // be hovered if it is not in the component tree
+ unsafeHovered.set(false)
+ return@enqueueRenderOperation
+ }
+
+ // Since enqueueRenderOperation will keep polling the queue until there are no more items,
+ // the forwarding of any update to the safeHovered state will still happen this frame
+ unsafeHovered.set(hitTestHovered())
+ }
+ }
+ }
+ }
+ onMouseEnter {
+ if (hitTest) {
+ unsafeHovered.set(hitTestHovered())
+ } else {
+ unsafeHovered.set(true)
+ }
+ }
+
+ onMouseLeave {
+ unsafeHovered.set(false)
+ }
+
+ return if (layoutSafe) {
+ unsafeHovered.onChange(this) { hovered ->
+ Window.enqueueRenderOperation {
+ safeHovered.set(hovered)
+ }
+ }
+ safeHovered
+ } else {
+ unsafeHovered
+ }
+}
+
+fun UIComponent.hoveredState(hitTest: Boolean = true, layoutSafe: Boolean = true): State =
+ hoveredStateV2(hitTest, layoutSafe).toV1(this)
+
+/** Marker effect for [makeHoverScope]/[hoverScope]. */
+private class HoverScope(val state: StateV2) : Effect()
+
+/**
+ * This method declares this component and its children to be part of one hover scope.
+ * Whether any component inside a hover scope is considered "hovered" depends on whether the scope declares it as such.
+ * By default the scope is considered hovered based on the [hoveredState] of this component but this may be overridden
+ * by passing a custom non-null [state].
+ *
+ * Scopes are resolved once on the first draw. As such they should be declared before the component is first drawn,
+ * cannot be removed, and are not updated if components are moved between different parents.
+ *
+ * If multiple scopes are nested, components within the inner scope will solely follow their direct parent scope and
+ * be completely oblivious to the outer scope.
+ * This can easily be customized by passing a different [state], e.g. passing
+ * `hoverScope(parentOnly = true) or hoveredState()` to make children appear as hovered when either the other or the
+ * inner scope is hovered.
+ *
+ * A hover scope may be re-declared on the same component to overwrite its source `state`. This allows a mostly
+ * self-contained component to declare a hover scope on itself by default; and if this default hover scope is not
+ * appropriate for some use case, the user may call `makeHoverScope` again on the component from the outside with a
+ * custom [state] (e.g. with `hoverScope(parentOnly = true)` to simply make it inherit from an outer scope as if it
+ * wasn't declared in the first place).
+ * Note that the same rules about first-time resolving still apply.
+ */
+fun UIComponent.makeHoverScope(state: State? = null) =
+ makeHoverScope(state?.toV2() ?: hoveredStateV2())
+
+fun UIComponent.makeHoverScope(state: StateV2) = apply {
+ removeEffect()
+ enableEffect(HoverScope(state))
+}
+
+/**
+ * Receives the hover scope which this component is subject to.
+ *
+ * This method must not be called on components which are not part of any hover scope.
+ *
+ * @see [makeHoverScope]
+ */
+fun UIComponent.hoverScopeV2(parentOnly: Boolean = false): StateV2 {
+ class HoverScopeConsumer : Effect() {
+ private val boundTo = mutableStateOf?>(null)
+ val state = StateV2 { (boundTo.getUntracked() ?: boundTo())?.invoke() ?: false }
+
+ override fun setup() {
+ val sequence = if (parentOnly) parent.selfAndParents() else selfAndParents()
+ val scope =
+ sequence.firstNotNullOfOrNull { component ->
+ component.effects.firstNotNullOfOrNull { it as? HoverScope }
+ } ?: throw IllegalStateException("No hover scope found for ${this@hoverScopeV2}.")
+ Window.enqueueRenderOperation {
+ boundTo.set(scope.state)
+ }
+ }
+ }
+ val consumer = HoverScopeConsumer()
+ enableEffect(consumer)
+ return consumer.state
+}
+
+fun UIComponent.hoverScope(parentOnly: Boolean = false): State =
+ hoverScopeV2(parentOnly).toV1(this)
+
+/** Once inherited, you can apply this to a component via [addTag] to be able to [findChildrenByTag]. */
+interface Tag
+
+/** Holder effect for a [Tag] */
+private class TagEffect(val tag: Tag) : Effect()
+
+/** Applies a [Tag] to this component. */
+fun UIComponent.addTag(tag: Tag) = apply { enableEffect(TagEffect(tag)) }
+
+/** Removes a [Tag] from this component. */
+fun UIComponent.removeTag(tag: Tag) = apply { effects.removeIf { it is TagEffect && it.tag == tag } }
+
+/** Returns a [Tag] of [T] which may or may not be attached to this component. */
+inline fun UIComponent.getTag(): T? = getTag(T::class.java)
+
+/** Returns a [Tag] of [T] which may or may not be attached to this component. */
+fun UIComponent.getTag(type: Class): T? {
+ val effect = effects.firstNotNullOfOrNull {
+ effect -> (effect as? TagEffect)?.takeIf { type.isInstance(it.tag) }
+ } ?: return null
+
+ return type.cast(effect.tag)
+}
+
+/**
+ * Searches for any children which contain a certain [Tag].
+ * See [addTag] for applying a [Tag] to a component.
+ */
+fun UIComponent.findChildrenByTag(tag: Tag, recursive: Boolean = false) = findChildrenByTag(recursive) { it == tag }
+
+/**
+ * Finds any children which have a tag which matches the [predicate].
+ * By default, this predicate will match any [Tag] of [T].
+ *
+ * See [addTag] for applying a [Tag] to a component.
+ */
+inline fun UIComponent.findChildrenByTag(
+ recursive: Boolean = false,
+ noinline predicate: UIComponent.(T) -> Boolean = { true },
+) = findChildrenByTag(T::class.java, recursive, predicate)
+
+/**
+ * Returns a map of [UIComponent]s (children) to their [Tag]s of [T].
+ * By default, this predicate will match any [Tag] of [T].
+ *
+ * See [addTag] for applying a [Tag] to a component.
+ */
+inline fun UIComponent.findChildrenAndTags(
+ recursive: Boolean = false,
+ noinline predicate: UIComponent.(T) -> Boolean = { true },
+) = findChildrenAndTags(T::class.java, recursive, predicate)
+
+/**
+ * Finds any children which have a tag which matches the [predicate].
+ * By default, this predicate will match any [Tag] of [T].
+ *
+ * See [addTag] for applying a [Tag] to a component.
+ */
+fun UIComponent.findChildrenByTag(
+ type: Class,
+ recursive: Boolean = false,
+ predicate: UIComponent.(T) -> Boolean = { true }
+): List {
+ val found = mutableListOf()
+
+ fun addToFoundIfHasTag(component: UIComponent) {
+ for (child in component.children) {
+ val tag = child.getTag(type)
+ if (tag != null && child.predicate(tag)) {
+ found.add(child)
+ }
+
+ if (recursive) {
+ addToFoundIfHasTag(child)
+ }
+ }
+ }
+
+ addToFoundIfHasTag(this)
+
+ return found
+}
+
+/**
+ * Returns a map of [UIComponent]s (children) to their [Tag]s of [T].
+ * By default, this predicate will match any [Tag] of [T].
+ *
+ * See [addTag] for applying a [Tag] to a component.
+ */
+fun UIComponent.findChildrenAndTags(
+ type: Class,
+ recursive: Boolean = false,
+ predicate: UIComponent.(T) -> Boolean = { true }
+): List> {
+ val found = mutableListOf>()
+
+ fun addToFoundIfHasTag(component: UIComponent) {
+ for (child in component.children) {
+ val tag = child.getTag(type)
+ if (tag != null && child.predicate(tag)) {
+ found.add(child to tag)
+ }
+
+ if (recursive) {
+ addToFoundIfHasTag(child)
+ }
+ }
+ }
+
+ addToFoundIfHasTag(this)
+
+ return found
+}
+
+/** Returns a [Sequence] consisting of this component and its parents (including the Window) in that order. */
+fun UIComponent.selfAndParents() =
+ generateSequence(this) { if (it.parent != it) it.parent else null }
+
+
+fun UIComponent.isComponentInParentChain(target: UIComponent): Boolean {
+ var component: UIComponent = this
+ while (component.hasParent && component !is Window) {
+ component = component.parent
+ if (component == target)
+ return true
+ }
+
+ return false
+}
+
+fun UIComponent.isInComponentTree(): Boolean =
+ this is Window || hasParent && this in parent.children && parent.isInComponentTree()
+
+fun ObservableList.onItemRemoved(callback: (E) -> Unit) {
+ addObserver { _, arg ->
+ if (arg is ObservableRemoveEvent<*>) {
+ callback(arg.element.value as E)
+ }
+ }
+}
+
+fun ObservableList.onItemAdded(callback: (E) -> Unit) {
+ addObserver { _, arg ->
+ if (arg is ObservableAddEvent<*>) {
+ callback(arg.element.value as E)
+ }
+ }
+}
+
+@Suppress("UNCHECKED_CAST")
+fun ObservableList.toStateV2List(): ListStateV2 {
+ val stateList = mutableStateOf(MutableTrackedList(this.toMutableList()))
+
+ this.addObserver { _, arg ->
+ when (arg) {
+ is ObservableAddEvent<*> -> stateList.add(arg.element.index, arg.element.value as E)
+ is ObservableClearEvent<*> -> stateList.clear()
+ is ObservableRemoveEvent<*> -> stateList.removeAt(arg.element.index)
+ }
+ }
+
+ return stateList
+}
diff --git a/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt
new file mode 100644
index 00000000..e919c3df
--- /dev/null
+++ b/unstable/layoutdsl/src/main/kotlin/gg/essential/elementa/util/focusable.kt
@@ -0,0 +1,100 @@
+package gg.essential.elementa.util
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.components.Window
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.state.v2.MutableState
+import gg.essential.elementa.state.v2.State
+import gg.essential.elementa.state.v2.combinators.or
+import gg.essential.elementa.state.v2.mutableStateOf
+import gg.essential.elementa.state.v2.stateOf
+import gg.essential.elementa.state.v2.toV2
+import gg.essential.elementa.layoutdsl.Modifier
+import gg.essential.elementa.layoutdsl.tag
+import gg.essential.elementa.layoutdsl.then
+import gg.essential.universal.UKeyboard
+
+data class Focusable(val disabled: State) : Tag
+
+/** Marks this component as [Focusable], meaning that it can be navigated to via the keyboard. */
+fun Modifier.focusable(disabled: State = stateOf(false)): Modifier {
+ return tag(Focusable(disabled))
+ .then {
+ val keyListener = setupKeyboardNavigation()
+ return@then { keyTypedListeners.remove(keyListener) }
+ }
+ .then { makeFocusOrHoverScope(); { throw NotImplementedError() } }
+}
+
+/** Creates a hover scope for this component based on whether it is hovered by the mouse OR it has the Window's focus. */
+private fun UIComponent.makeFocusOrHoverScope() {
+ val focused = focusedState()
+ val hovered = hoveredState().toV2()
+
+ makeHoverScope(focused or hovered)
+}
+
+/** Returns a state indicating whether this component has the [Window]'s focus or not. */
+fun UIComponent.focusedState(): State {
+ class CachedState(val state: State) : Tag
+ getTag()?.let { return it.state }
+
+ val state = mutableStateOf(Window.ofOrNull(this)?.focusedComponent == this)
+
+ onFocus { state.set(true) }
+ onFocusLost { state.set(false) }
+ addTag(CachedState(state))
+
+ return state
+}
+/**
+ * Reacts to keyboard-navigation related events if the component is focused.
+ * @return The key listener, mainly intended for removing it at a future point in time.
+ */
+private fun UIComponent.setupKeyboardNavigation(): UIComponent.(Char, Int) -> Unit {
+ val keyListener: UIComponent.(Char, Int) -> Unit = keyListener@{ _, keyCode ->
+ if (!hasFocus()) {
+ return@keyListener
+ }
+
+ when (keyCode) {
+ UKeyboard.KEY_ENTER -> simulateLeftClick()
+ UKeyboard.KEY_TAB -> passFocusToNextComponent(backwards = UKeyboard.isShiftKeyDown())
+ }
+ }
+
+ onKeyType(keyListener)
+
+ return keyListener
+}
+
+/** Intended for use by keyboard navigation implementations in order to fake a left-click event on a component. */
+fun UIComponent.simulateLeftClick() {
+ // We need to make sure that we're still in the window, as another key listener which ran before us
+ // may have already reacted to the event. This function isn't exactly a key-listener, but is most
+ // likely being called from one.
+ if (!isInComponentTree()) {
+ return
+ }
+
+ mouseClick(
+ getLeft().toDouble() + (getWidth() / 2),
+ getTop().toDouble() + (getHeight() / 2),
+ 0,
+ )
+}
+
+private fun UIComponent.passFocusToNextComponent(backwards: Boolean = false) {
+ val focusable = Window.of(this).findChildrenByTag(recursive = true) {
+ this == this@passFocusToNextComponent || !it.disabled.getUntracked()
+ }
+
+ val currentIndex = focusable.indexOf(this)
+ if (currentIndex == -1) {
+ return
+ }
+
+ val direction = if (backwards) -1 else 1
+ val nextComponent = focusable[(currentIndex + direction).mod(focusable.size)]
+ nextComponent.grabWindowFocus()
+}
\ No newline at end of file
diff --git a/unstable/statev2/build.gradle.kts b/unstable/statev2/build.gradle.kts
new file mode 100644
index 00000000..61904140
--- /dev/null
+++ b/unstable/statev2/build.gradle.kts
@@ -0,0 +1,39 @@
+import gg.essential.gradle.multiversion.StripReferencesTransform.Companion.registerStripReferencesAttribute
+import gg.essential.gradle.util.setJvmDefault
+import gg.essential.gradle.util.versionFromBuildIdAndBranch
+
+plugins {
+ kotlin("jvm")
+ id("gg.essential.defaults")
+ id("gg.essential.defaults.maven-publish")
+}
+
+version = versionFromBuildIdAndBranch()
+group = "gg.essential"
+
+dependencies {
+ compileOnly(project(":"))
+ compileOnly(libs.kotlinx.coroutines.core)
+
+ val common = registerStripReferencesAttribute("common") {
+ excludes.add("net.minecraft")
+ }
+ compileOnly(libs.versions.universalcraft.map { "gg.essential:universalcraft-1.8.9-forge:$it" }) {
+ attributes { attribute(common, true) }
+ }
+}
+tasks.compileKotlin.setJvmDefault("all")
+
+kotlin.jvmToolchain {
+ (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(8))
+}
+
+java.withSourcesJar()
+
+publishing {
+ publications {
+ named("maven") {
+ artifactId = "elementa-unstable-${project.name}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt
new file mode 100644
index 00000000..dc536ff1
--- /dev/null
+++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/animate.kt
@@ -0,0 +1,88 @@
+package gg.essential.elementa.state.v2
+
+import gg.essential.elementa.UIComponent
+import gg.essential.elementa.components.Window
+import gg.essential.elementa.constraints.animation.AnimationStrategy
+import gg.essential.elementa.constraints.animation.Animations
+import gg.essential.elementa.effects.Effect
+import gg.essential.elementa.state.v2.ReferenceHolder
+import java.lang.ref.WeakReference
+
+fun State.animateTransitions(
+ driverComponent: UIComponent,
+ duration: Float,
+ animationStrategy: AnimationStrategy = Animations.OUT_EXP,
+): State {
+ if (duration <= 0f) {
+ return this
+ }
+ val resultState = mutableStateOf(this.getUntracked())
+ driverComponent.enableEffect(AnimationDriver(this, WeakReference(resultState), duration, animationStrategy))
+ return resultState
+}
+
+private class AnimationDriver(
+ private val driver: State,
+ private val resultStateWeakReference: WeakReference>,
+ private val duration: Float,
+ private val animationStrategy: AnimationStrategy
+): Effect() {
+ private val animationEventList = mutableListOf()
+ private lateinit var driverEffect: () -> Unit
+ private var durationFrames = 1
+
+ private var previousDriverStateValue = 0f
+ private var isDestroying = false
+
+ override fun setup() {
+ previousDriverStateValue = driver.getUntracked()
+ durationFrames = (Window.of(boundComponent).animationFPS * duration).toInt().coerceAtLeast(1)
+ driverEffect = effect(ReferenceHolder.Weak) {
+ val input = driver()
+ animationEventList.add(AnimationEvent(previousDriverStateValue, input, durationFrames))
+ previousDriverStateValue = input
+ }
+ }
+
+ override fun animationFrame() {
+ val resultState = resultStateWeakReference.get()
+ if (resultState == null) {
+ destroy()
+ } else {
+ animationEventList.forEach { it.age++ }
+ animationEventList.removeIf { it.age >= durationFrames }
+ resultState.set(getAnimationValue())
+ }
+ }
+
+ private fun destroy() {
+ if (isDestroying) {
+ return
+ }
+ isDestroying = true
+ driverEffect()
+ Window.enqueueRenderOperation {
+ boundComponent.removeEffect(this)
+ }
+ }
+
+ private fun getAnimationValue(): Float {
+ if (animationEventList.isEmpty()) {
+ return previousDriverStateValue
+ }
+
+ return animationEventList.fold(animationEventList.first().startValue) { acc, event ->
+ val linearProgress = event.age.toFloat() / event.duration.toFloat()
+ val animatedProgress = animationStrategy.getValue(linearProgress)
+ acc + ((event.endValue - acc) * animatedProgress)
+ }
+ }
+
+ private data class AnimationEvent(
+ val startValue: Float,
+ val endValue: Float,
+ val duration: Int,
+ var age: Int = 0,
+ )
+
+}
\ No newline at end of file
diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt
new file mode 100644
index 00000000..b6bb88fd
--- /dev/null
+++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedList.kt
@@ -0,0 +1,217 @@
+package gg.essential.elementa.state.v2.collections
+
+import kotlin.collections.AbstractList
+
+/**
+ * An immutable List type that remembers the changes that have been applied to it, allowing one to very cheaply obtain
+ * a "diff" between its current and an older version via [getChangesSince].
+ *
+ * To maintain good performance, the implementation assumes that the standard use case involves only a single chain
+ * of changes and that older lists are only ever compared to newer list, not read from directly.
+ * For this standard use case, it maintains performance characteristics similar to the regular [mutableListOf] in terms
+ * of memory and runtime.
+ *
+ * Non-standard use cases are supported but will generally have performance of `O(n+m)` where `n` is the size of the
+ * latest list and `m` is the amount of changes that have happened between this list and the latest list (i.e. only the
+ * latest list contains the full array of values, previous lists only contain a change and their successor list).
+ */
+class MutableTrackedList private constructor(
+ /** Counter increased with every change. Used to quickly determine which of two lists is older. */
+ private val generation: Int,
+ realList: MutableList,
+) : AbstractList(), TrackedList {
+
+ private var maybeRealList: MutableList? = realList
+ private val realList: MutableList
+ get() = maybeRealList ?: computeRealList()
+
+ private var nextList: MutableTrackedList? = null
+ private var nextDiff: Diff? = null
+
+ /** Computes the real list for this list from the next list(s). */
+ private fun computeRealList(): MutableList {
+ val generations = generateSequence(this) { if (it.maybeRealList != null) null else it.nextList }.toList()
+ val list = generations.last().realList.toMutableList()
+ for (i in generations.indices.reversed()) {
+ generations[i].nextDiff?.revert(list)
+ }
+ maybeRealList = list
+ return list
+ }
+
+ /** Creates a child list based on this list with the given diff. */
+ private fun fork(
+ diff: Diff,
+ child: MutableTrackedList = MutableTrackedList(generation + 1, realList),
+ ): MutableTrackedList {
+
+ // Relinquish ownership of our real list, it now belongs to the child
+ maybeRealList = null
+
+ // We only want to update our next pointer if we don't yet have one, otherwise we risk changing the result of
+ // future diff calls (compared to what they previously returned).
+ if (nextList == null) {
+ nextList = child
+ nextDiff = diff
+ }
+
+ // Finally, apply the diff
+ diff.apply(child.realList)
+
+ return child
+ }
+
+ override fun getChangesSince(other: TrackedList): Sequence> {
+ return if (other is MutableTrackedList) {
+ getChangesSince(other)
+ } else {
+ TrackedList.Change.estimate(other, this).asSequence()
+ }
+ }
+
+ fun getChangesSince(other: MutableTrackedList): Sequence> {
+ // Trivial case: no changes
+ if (other == this) {
+ return emptySequence()
+ }
+
+ // Fast path: single diff only
+ if (other.nextList == this) {
+ return other.nextDiff!!.asChangeSequence()
+ }
+
+ if (other.generation < this.generation) {
+ // Regular diff
+ val generations = generateSequence(other) { if (it == this) null else it.nextList }.toMutableList()
+ if (generations.removeLast() != this) return TrackedList.Change.estimate(other, this).asSequence()
+ return generations.asSequence().flatMap { it.nextDiff!!.asChangeSequence() }
+ } else {
+ // Reverse diff
+ val generations = generateSequence(this) { if (it == other) null else it.nextList }.toMutableList()
+ if (generations.removeLast() != other) return TrackedList.Change.estimate(other, this).asSequence()
+ return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() }
+ }
+ }
+
+ constructor(mutableList: MutableList = mutableListOf()) : this(0, mutableList)
+
+ override val size: Int
+ get() = realList.size
+
+ override fun get(index: Int): E = realList[index]
+
+ fun set(index: Int, element: E) =
+ fork(Diff.Multiple(listOf(Diff.Removal(index, realList[index]), Diff.Addition(index, element))))
+
+ fun add(element: E) = add(size, element)
+ fun add(index: Int, element: E) = fork(Diff.Addition(index, element))
+ fun addAll(elements: Collection) = addAll(size, elements)
+ fun addAll(index: Int, elements: Collection) = fork(Diff.Multiple(elements.mapIndexed { i, e -> Diff.Addition(index + i, e) }))
+
+ fun clear(): MutableTrackedList = fork(Diff.Clear(realList), MutableTrackedList(generation + 1, mutableListOf()))
+
+ fun remove(element: E): MutableTrackedList {
+ val index = indexOf(element)
+ return if (index == -1) this else fork(Diff.Removal(index, element))
+ }
+ fun removeAt(index: Int) = fork(Diff.Removal(index, this[index]))
+ fun removeAll(elements: Collection): MutableTrackedList {
+ val diffs = elements.mapNotNull { element ->
+ val index = indexOf(element)
+ if (index == -1) null else Diff.Removal(index, element)
+ }.sortedBy { -it.index }
+ return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs))
+ }
+ fun retainAll(elements: Collection): MutableTrackedList {
+ val diffs = realList.mapIndexedNotNull { index, element ->
+ if (element in elements) null else Diff.Removal(index, element)
+ }.reversed()
+ return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs))
+ }
+
+ fun applyChanges(changes: List>): MutableTrackedList {
+ if (changes.isEmpty()) return this
+ return fork(changes.map {
+ when (it) {
+ is TrackedList.Add -> Diff.Addition(it.element.index, it.element.value)
+ is TrackedList.Remove -> Diff.Removal(it.element.index, it.element.value)
+ is TrackedList.Clear -> Diff.Clear(it.oldElements.toList())
+ }
+ }.let { it.singleOrNull() ?: Diff.Multiple(it) })
+ }
+
+ private sealed interface Diff {
+ fun apply(list: MutableList)
+ fun revert(list: MutableList)
+ fun asChangeSequence(): Sequence>
+ fun asInverseChangeSequence(): Sequence>
+
+ data class Addition(val index: Int, val element: E) : Diff {
+ override fun apply(list: MutableList) {
+ list.add(index, element)
+ }
+
+ override fun revert(list: MutableList) {
+ list.removeAt(index)
+ }
+
+ override fun asChangeSequence(): Sequence> =
+ sequenceOf(TrackedList.Add(IndexedValue(index, element)))
+
+ override fun asInverseChangeSequence(): Sequence> =
+ sequenceOf(TrackedList.Remove(IndexedValue(index, element)))
+ }
+
+ data class Removal(val index: Int, val element: E) : Diff {
+ override fun apply(list: MutableList) {
+ list.removeAt(index)
+ }
+
+ override fun revert(list: MutableList) {
+ list.add(index, element)
+ }
+
+ override fun asChangeSequence(): Sequence> =
+ sequenceOf(TrackedList.Remove(IndexedValue(index, element)))
+
+ override fun asInverseChangeSequence(): Sequence> =
+ sequenceOf(TrackedList.Add(IndexedValue(index, element)))
+ }
+
+ data class Clear(val oldList: List) : Diff {
+ override fun apply(list: MutableList) {
+ list.clear()
+ }
+
+ override fun revert(list: MutableList) {
+ list.addAll(oldList)
+ }
+
+ override fun asChangeSequence(): Sequence> =
+ sequenceOf(TrackedList.Clear(oldList))
+
+ override fun asInverseChangeSequence(): Sequence> =
+ oldList.withIndex().asSequence().map { TrackedList.Add(it) }
+ }
+
+ data class Multiple(val diffs: List>) : Diff {
+ override fun revert(list: MutableList) {
+ for (i in diffs.indices.reversed()) {
+ diffs[i].revert(list)
+ }
+ }
+
+ override fun apply(list: MutableList) {
+ for (change in diffs) {
+ change.apply(list)
+ }
+ }
+
+ override fun asChangeSequence(): Sequence> =
+ diffs.asSequence().flatMap { it.asChangeSequence() }
+
+ override fun asInverseChangeSequence(): Sequence> =
+ diffs.asReversed().asSequence().flatMap { it.asInverseChangeSequence() }
+ }
+ }
+}
diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt
new file mode 100644
index 00000000..702bceaf
--- /dev/null
+++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/MutableTrackedSet.kt
@@ -0,0 +1,214 @@
+package gg.essential.elementa.state.v2.collections
+
+/**
+ * An immutable Set type that remembers the changes that have been applied to it, allowing one to very cheaply obtain
+ * a "diff" between its current and an older version via [getChangesSince].
+ *
+ * To maintain good performance, the implementation assumes that the standard use case involves only a single chain
+ * of changes and that older sets are only ever compared to newer sets, not read from directly.
+ * For this standard use case, it maintains performance characteristics similar to the regular [mutableSetOf] in terms
+ * of memory and runtime.
+ *
+ * Non-standard use cases are supported but will generally have performance of `O(n+m)` where `n` is the size of the
+ * latest set and `m` is the amount of changes that have happened between this set and the latest set (i.e. only the
+ * latest set contains the full array of values, previous sets only contain a change and their successor set).
+ *
+ * In the standard use case, the iteration order for the latest version matches insertion order. For all other use cases
+ * and versions, iteration order is undefined.
+ */
+class MutableTrackedSet private constructor(
+ /** Counter increased with every change. Used to quickly determine which of two sets is older. */
+ private val generation: Int,
+ realSet: MutableSet,
+) : AbstractSet(), TrackedSet {
+
+ private var maybeRealSet: MutableSet? = realSet
+ private val realSet: MutableSet
+ get() = maybeRealSet ?: computeRealSet()
+
+ private var nextSet: MutableTrackedSet? = null
+ private var nextDiff: Diff? = null
+
+ /** Computes the real set for this set from the next set(s). */
+ private fun computeRealSet(): MutableSet {
+ val generations = generateSequence(this) { if (it.maybeRealSet != null) null else it.nextSet }.toList()
+ val set = generations.last().realSet.toMutableSet()
+ for (i in generations.indices.reversed()) {
+ generations[i].nextDiff?.revert(set)
+ }
+ maybeRealSet = set
+ return set
+ }
+
+ /** Creates a child set based on this set with the given diff. */
+ private fun fork(
+ diff: Diff