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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
mcVersionmcPlatformbuildNumber
1.18.1fabric - 1.18.1-fabric -
1.18.1forge - 1.18.1-forge -
1.17.1fabric - 1.17.1-fabric -
1.17.1forge - 1.17.1-forge -
1.16.2forge - 1.16.2-forge -
1.12.2forge - 1.12.2-forge -
1.8.9forge1.8.9-forge
- -
- -If you were previously using v1.7.1 of Elementa and are now on the v2.0.0 builds, please refer to the -[migration](docs/migration.md) document to know what has changed. - -To learn about all the new features in v2.0.0, please read the [what's new](docs/whatsnew.md) document. +gg.essential:elementa +gg.essential:universalcraft-1.8.9-forge

IMPORTANT!

-If you are using forge, you must also relocate Elementa to avoid potential crashes with other mods. To do this, you will need to use the Shadow Gradle plugin. +If you are using Forge, you must also relocate Elementa to avoid incompatibility with other mods. +To do this, you may use the Shadow Gradle plugin:
Groovy Version @@ -191,6 +127,11 @@ In your dependencies block, add: implementation "club.sk1er:Elementa:1.7.1-$mcVersion" ``` +If you were previously using v1.7.1 of Elementa and are now on the v2.0.0 builds, please refer to the +[migration](docs/migration.md) document to know what has changed. + +To learn about all the new features in v2.0.0, please read the [what's new](docs/whatsnew.md) document. + ## Components All the drawing in Elementa is done via UIComponents. There is a root component named `Window` diff --git a/api/Elementa.api b/api/Elementa.api index 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, + child: MutableTrackedSet = MutableTrackedSet(generation + 1, realSet), + ): MutableTrackedSet { + + // Relinquish ownership of our real set, it now belongs to the child + maybeRealSet = null + + // We only want to update our next pointer if we don't yet have one, otherwise we risk changing the result of + // future diff calls (compared to what they previously returned). + if (nextSet == null) { + nextSet = child + nextDiff = diff + } + + // Finally, apply the diff + diff.apply(child.realSet) + + return child + } + + override fun getChangesSince(other: TrackedSet): Sequence> { + return if (other is MutableTrackedSet) { + getChangesSince(other) + } else { + TrackedSet.Change.estimate(other, this).asSequence() + } + } + + fun getChangesSince(other: MutableTrackedSet): Sequence> { + // Trivial case: no changes + if (other == this) { + return emptySequence() + } + + // Fast path: single diff only + if (other.nextSet == this) { + return other.nextDiff!!.asChangeSequence() + } + + if (other.generation < this.generation) { + // Regular diff + val generations = generateSequence(other) { if (it == this) null else it.nextSet }.toMutableList() + if (generations.removeLast() != this) return TrackedSet.Change.estimate(other, this).asSequence() + return generations.asSequence().flatMap { it.nextDiff!!.asChangeSequence() } + } else { + // Reverse diff + val generations = generateSequence(this) { if (it == other) null else it.nextSet }.toMutableList() + if (generations.removeLast() != other) return TrackedSet.Change.estimate(other, this).asSequence() + return generations.asReversed().asSequence().flatMap { it.nextDiff!!.asInverseChangeSequence() } + } + } + + constructor(mutableSet: MutableSet = mutableSetOf()) : this(0, mutableSet) + + override val size: Int + get() = realSet.size + + override fun iterator(): Iterator = realSet.iterator() + override fun contains(element: E): Boolean = realSet.contains(element) + + fun add(element: E) = if (element in this) this else fork(Diff.Addition(element)) + fun addAll(elements: Collection): MutableTrackedSet { + val diffs = elements.mapNotNull { element -> + if (element in this) null else Diff.Addition(element) + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun clear(): MutableTrackedSet = fork(Diff.Clear(realSet), MutableTrackedSet(generation + 1, mutableSetOf())) + + fun remove(element: E) = if (element in this) fork(Diff.Removal(element)) else this + fun removeAll(elements: Collection): MutableTrackedSet { + val diffs = elements.mapNotNull { element -> + if (element in this) Diff.Removal(element) else null + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + fun retainAll(elements: Collection): MutableTrackedSet { + val diffs = realSet.mapNotNull { element -> + if (element in elements) null else Diff.Removal(element) + } + return if (diffs.isEmpty()) this else fork(Diff.Multiple(diffs)) + } + + fun applyChanges(changes: List>): MutableTrackedSet { + if (changes.isEmpty()) return this + return fork(changes.map { + when (it) { + is TrackedSet.Add -> Diff.Addition(it.element) + is TrackedSet.Remove -> Diff.Removal(it.element) + is TrackedSet.Clear -> Diff.Clear(it.oldElements.toSet()) + } + }.let { it.singleOrNull() ?: Diff.Multiple(it) }) + } + + private sealed interface Diff { + fun apply(set: MutableSet) + fun revert(set: MutableSet) + fun asChangeSequence(): Sequence> + fun asInverseChangeSequence(): Sequence> + + data class Addition(val element: E) : Diff { + override fun apply(set: MutableSet) { + set.add(element) + } + + override fun revert(set: MutableSet) { + set.remove(element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Add(element)) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Remove(element)) + } + + data class Removal(val element: E) : Diff { + override fun apply(set: MutableSet) { + set.remove(element) + } + + override fun revert(set: MutableSet) { + set.add(element) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Remove(element)) + + override fun asInverseChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Add(element)) + } + + data class Clear(val oldSet: Set) : Diff { + override fun apply(set: MutableSet) { + set.clear() + } + + override fun revert(set: MutableSet) { + set.addAll(oldSet) + } + + override fun asChangeSequence(): Sequence> = + sequenceOf(TrackedSet.Clear(oldSet)) + + override fun asInverseChangeSequence(): Sequence> = + oldSet.asSequence().map { TrackedSet.Add(it) } + } + + data class Multiple(val diffs: List>) : Diff { + override fun revert(set: MutableSet) { + for (i in diffs.indices.reversed()) { + diffs[i].revert(set) + } + } + + override fun apply(set: MutableSet) { + for (change in diffs) { + change.apply(set) + } + } + + override fun asChangeSequence(): Sequence> = + diffs.asSequence().flatMap { it.asChangeSequence() } + + override fun asInverseChangeSequence(): Sequence> = + diffs.asReversed().asSequence().flatMap { it.asInverseChangeSequence() } + } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt new file mode 100644 index 00000000..67dbeee9 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedList.kt @@ -0,0 +1,89 @@ +package gg.essential.elementa.state.v2.collections + +/** + * An immutable List type that remembers the changes that have been applied to construct it, allowing one to very + * cheaply obtain a "diff" between it and one of the lists it has been constructed from. + * + * The exact meaning of "very cheaply" may differ depending on the specific implementation but should never be worse + * than `O(n+m)` where `n` is the size of both lists and `m` is the amount of changes that have happened between + * two lists. In the best case it should just be `O(m)`. + * + * If two unrelated tracked lists are compared with each other, the result will usually just equal [Change.estimate], + * which takes `O(n)`. + * + * Beware that even though lists of this type appear to be immutable, they are not guaranteed to be internally immutable + * (for performance reasons) and as such are not generally thread-safe. + */ +interface TrackedList : List { + /** Returns changes one would have to apply to [other] to obtain `this`. */ + fun getChangesSince(other: TrackedList<@UnsafeVariance E>): Sequence> + + data class Add(val element: IndexedValue) : Change + data class Remove(val element: IndexedValue) : Change + data class Clear(val oldElements: List) : Change + + sealed interface Change { + companion object { + /** + * Estimates the changes one would have to apply to [oldList] to obtain [newList]. + * + * Note that while the estimate is correct (i.e. the changes will result in [newList]), it is not + * necessarily minimal (i.e. there may be a shorter list of changes that would also result in [newList]), + * nor is it accurate (i.e. even if both arguments are [MutableTrackedList]s, the returned changes may + * differ from how those lists were actually created). + * + * The result is however minimal if only one of additions, removals, or updates (`set`) were applied between + * [oldList] and [newList]. It may also be minimal if a mix of these was applied but no guarantees are made + * in that case. + */ + fun estimate(oldList: List, newList: List): List> { + return if (newList.isEmpty()) { + if (oldList.isEmpty()) { + emptyList() + } else { + listOf(Clear(oldList)) + } + } else { + val changes = mutableListOf>() + + var oldIndex = 0 + var newIndex = 0 + + while (oldIndex <= oldList.lastIndex && newIndex <= newList.lastIndex) { + val oldValue = oldList[oldIndex] + val newValue = newList[newIndex] + if (oldValue == newValue) { + oldIndex++ + newIndex++ + continue + } + if (newList.size == oldList.size) { + changes.add(Remove(IndexedValue(newIndex, oldValue))) + changes.add(Add(IndexedValue(newIndex, newValue))) + oldIndex++ + newIndex++ + } else if (newList.size - newIndex > oldList.size - oldIndex) { + changes.add(Add(IndexedValue(newIndex, newValue))) + newIndex++ + } else { + changes.add(Remove(IndexedValue(newIndex, oldValue))) + oldIndex++ + } + } + + while (newIndex <= newList.lastIndex) { + changes.add(Add(IndexedValue(newIndex, newList[newIndex]))) + newIndex++ + } + + while (oldIndex <= oldList.lastIndex) { + changes.add(Remove(IndexedValue(newIndex, oldList[oldIndex]))) + oldIndex++ + } + + changes + } + } + } + } +} \ No newline at end of file diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt new file mode 100644 index 00000000..1002190c --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/TrackedSet.kt @@ -0,0 +1,56 @@ +package gg.essential.elementa.state.v2.collections + +/** + * An immutable Set type that remembers the changes that have been applied to construct it, allowing one to very + * cheaply obtain a "diff" between it and one of the sets it has been constructed from. + * + * The exact meaning of "very cheaply" may differ depending on the specific implementation but should never be worse + * than `O(n+m)` where `n` is the size of both sets and `m` is the amount of changes that have happened between + * two sets. In the best case it should just be `O(m)`. + * + * If two unrelated tracked sets are compared with each other, the result will usually just equal [Change.estimate], + * which takes `O(n)`. + * + * Beware that even though sets of this type appear to be immutable, they are not guaranteed to be internally immutable + * (for performance reasons) and as such are not generally thread-safe. + */ +interface TrackedSet : Set { + /** Returns changes one would have to apply to [other] to obtain `this`. */ + fun getChangesSince(other: TrackedSet<@UnsafeVariance E>): Sequence> + + data class Add(val element: E) : Change + data class Remove(val element: E) : Change + data class Clear(val oldElements: Set) : Change + + sealed interface Change { + companion object { + /** + * Estimates the changes one would have to apply to [oldSet] to obtain [newSet]. + */ + fun estimate(oldSet: Set, newSet: Set): List> { + return if (newSet.isEmpty()) { + if (oldSet.isEmpty()) { + emptyList() + } else { + listOf(Clear(oldSet)) + } + } else { + val changes = mutableListOf>() + + for (newValue in newSet) { + if (newValue !in oldSet) { + changes.add(Add(newValue)) + } + } + for (oldValue in oldSet) { + if (oldValue !in newSet) { + changes.add(Remove(oldValue)) + } + } + + changes + } + } + } + } +} \ No newline at end of file diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt new file mode 100644 index 00000000..b97f714b --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/collections/utils.kt @@ -0,0 +1,35 @@ +package gg.essential.elementa.state.v2.collections + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.ListState + +// FIXME this is assuming there are no duplicate keys (good enough for now) +fun ListState.asMap(owner: ReferenceHolder, block: (T) -> Pair): Map { + var oldList = get() + val map = oldList.associateTo(mutableMapOf(), block) + val keys = map.keys.toMutableList() + onSetValue(owner) { newList -> + val changes = newList.getChangesSince(oldList).also { oldList = newList } + for (change in changes) { + when (change) { + is TrackedList.Add -> { + val (k, v) = block(change.element.value) + keys.add(change.element.index, k) + map[k] = v + } + is TrackedList.Remove -> { + map.remove(keys.removeAt(change.element.index)) + } + is TrackedList.Clear -> { + map.clear() + keys.clear() + } + } + } + } + return map +} + +fun trackedListOf(vararg elements: T) : TrackedList = MutableTrackedList(elements.toMutableList()) + +fun mutableTrackedListOf(vararg elements: T): MutableTrackedList = MutableTrackedList(elements.toMutableList()) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt new file mode 100644 index 00000000..b2a12431 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/color/color.kt @@ -0,0 +1,11 @@ +package gg.essential.elementa.state.v2.color + +import gg.essential.elementa.constraints.ColorConstraint +import gg.essential.elementa.dsl.basicColorConstraint +import gg.essential.elementa.state.v2.State +import java.awt.Color + +fun State.toConstraint() = basicColorConstraint { get() } + +val State.constraint: ColorConstraint + get() = toConstraint() diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt new file mode 100644 index 00000000..afa9cb32 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/booleans.kt @@ -0,0 +1,14 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State + +infix fun State.and(other: State) = + zip(other) { a, b -> a && b } + +infix fun State.or(other: State) = + zip(other) { a, b -> a || b } + +operator fun State.not() = map { !it } + +operator fun MutableState.not() = bimap({ !it }, { !it }) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt new file mode 100644 index 00000000..4a3b6ffa --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/pair.kt @@ -0,0 +1,6 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.State + +operator fun State>.component1(): State = this.map { it.first } +operator fun State>.component2(): State = this.map { it.second } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt new file mode 100644 index 00000000..539aefa3 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/state.kt @@ -0,0 +1,27 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.memo + +/** Maps this state into a new state */ +fun State.map(mapper: (T) -> U): State { + return memo { mapper(get()) } +} + +/** Maps this mutable state into a new mutable state. */ +fun MutableState.bimap(map: (T) -> U, unmap: (U) -> T): MutableState { + return object : MutableState, State by this.map(map) { + override fun set(mapper: (U) -> U) { + this@bimap.set { unmap(mapper(map(it))) } + } + } +} + +/** Zips this state with another state */ +fun State.zip(other: State): State> = zip(other, ::Pair) + +/** Zips this state with another state using [mapper] */ +fun State.zip(other: State, mapper: (T, U) -> V): State { + return memo { mapper(this@zip(), other()) } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt new file mode 100644 index 00000000..0eb99655 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/strings.kt @@ -0,0 +1,10 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.State + +fun State.contains(other: State, ignoreCase: Boolean = false) = + zip(other) { a, b -> a.contains(b, ignoreCase) } + +fun State.isEmpty() = map { it.isEmpty() } + +fun State.isNotEmpty() = map { it.isNotEmpty() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt new file mode 100644 index 00000000..1d555a53 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/combinators/utils.kt @@ -0,0 +1,6 @@ +package gg.essential.elementa.state.v2.combinators + +import gg.essential.elementa.state.v2.MutableState + +fun MutableState.reorder(vararg mapping: Int) = + bimap({ mapping[it] }, { mapping.indexOf(it) }) diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt new file mode 100644 index 00000000..a92255a5 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/compatibility.kt @@ -0,0 +1,95 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import java.util.function.Consumer +import gg.essential.elementa.state.State as V1State + +private class V2AsV1State(private val v2State: State, owner: ReferenceHolder) : V1State() { + // Stored in a field, so the listener is kept alive at least as long as this legacy state instance exists + private val listener: (T) -> Unit = { super.set(it) } + + init { + v2State.onSetValue(owner, listener) + } + + override fun get(): T = v2State.get() + + override fun set(value: T) { + if (v2State is MutableState<*>) { + (v2State as MutableState).set { value } + } else { + super.set(value) + } + } +} + +/** + * Converts this state into a v1 [State][V1State]. + * + * If [V1State.set] is called on the returned state and this value is a [MutableState], then the call is forwarded to + * [MutableState.set], otherwise only the internal field of the v1 state will be updated (and overwritten again the next + * time this state changes; much like the old mapped states). + * + * Note that as with any listener on a v2 state, the returned v1 state may be garbage collected once there are no more + * strong references to it. This v2 state will not by itself keep it alive. + * The [owner] argument serves to prevent this from happening too early, see [State.onSetValue]. + */ +fun State.toV1(owner: ReferenceHolder): V1State = V2AsV1State(this, owner) + +/** + * Converts this state into a v2 [MutableState]. + * + * The returned state is registered as a listener on the v1 state and as such will live as long as the v1 state. + * This matches v1 state behavior. If this is not desired, stop using v1 state. + */ +fun V1State.toV2(): MutableState { + val referenceHolder = ReferenceHolderImpl() + val v1 = this + val v2 = mutableStateOf(get()) + + v2.onSetValue(referenceHolder) { value -> + if (v1.get() != value) { + v1.set(value) + } + } + v1.onSetValue(object : Consumer { + @Suppress("unused") // keep this alive for as long as the v1 state + val referenceHolder = referenceHolder + + override fun accept(value: T) { + v2.set(value) + } + }) + + return v2 +} + +/** + * Returns a delegating state with internal mutability. That is, the value of the returned state generally follows the + * value of the input state (or the state passed to [DelegatingState.rebind]), but [MutableState.set] is not forwarded + * to the bound state. Instead the new value is stored internally and returned until the input state changes again, at + * which point it'll be overwritten again. + * + * Using such a state (`input.map { it }`) with a `rebindState` method and direct getter+setter methods for the state + * content was a common anti-pattern used in many places throughout Element. + * To preserve backwards compatibility for this behavior, this method exists to quickly construct such a state in the v2 + * world. + * New code should instead just use a regular delegating state and have the setter rebind it to a new immutable state. + */ +internal fun State.wrapWithDelegatingMutableState(): MutableDelegatingState { + val delegatingState = stateDelegatingTo(this) + val derivedState = + derivedState(get()) { owner, derivedState -> + delegatingState.onSetValue(owner) { derivedState.set(it) } + } + // Note: this in an implementation detail of `derivedState`, do not rely on it outside of Elementa + val mutableState = derivedState as MutableState + + return object : DelegatingState, MutableState by mutableState, MutableDelegatingState { + override fun rebind(newState: State) { + delegatingState.rebind(newState) + } + } +} + +internal interface MutableDelegatingState : DelegatingState, MutableState diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt new file mode 100644 index 00000000..88483488 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/coroutine.kt @@ -0,0 +1,37 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** Waits until this [State] has a value which [equals] the given [value]. */ +suspend fun State.awaitValue(value: T): T = await { it == value } + +/** Waits until this [State] has a value for which [accept] returns `true` and returns that value. */ +suspend fun State.await(accept: (T) -> Boolean): T { + // Fast-path + get().let { if (accept(it)) return it } + + // Slow path + return suspendCancellableCoroutine { continuation -> + lateinit var unregister: () -> Unit + var listener: ((T) -> Unit)? + listener = { value -> + if (accept(value)) { + unregister() + continuation.resume(value) + } + } + unregister = onSetValue(ReferenceHolder.Weak, listener) + listener(get()) + continuation.invokeOnCancellation { + // Note: we cannot call `unregister` here because `invokeOnCancellation` makes no guarantee about which + // thread we run on, and `unregister` isn't thread safe. + // So we'll instead merely drop our reference to the listener and leave it to State's weakness properties + // to clean up the registration. + // This does mean our callback will continue to be invoked, but `CancellableCoroutine` is fine with that + // because cancellation may race with `resume` in pretty much any code. + listener = null + } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt new file mode 100644 index 00000000..5b4f9b5e --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/flatten.kt @@ -0,0 +1,3 @@ +package gg.essential.elementa.state.v2 + +fun State>.flatten() = stateBy { this@flatten()() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt new file mode 100644 index 00000000..13451b74 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/Impl.kt @@ -0,0 +1,45 @@ +package gg.essential.elementa.state.v2.impl + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.DelegatingMutableState +import gg.essential.elementa.state.v2.DelegatingState +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ReferenceHolderImpl +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.mutableStateOf + +internal interface Impl { + fun mutableState(value: T): MutableState + fun memo(func: Observer.() -> T): State + fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit + + fun stateDelegatingTo(state: State): DelegatingState = + object : DelegatingState { + private val target = mutableStateOf(state) + override fun rebind(newState: State) = target.set(newState) + override fun Observer.get(): T = target()() + } + + fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = + object : DelegatingMutableState { + private val target = mutableStateOf(state) + override fun set(mapper: (T) -> T) = target.getUntracked().set(mapper) + override fun rebind(newState: MutableState) = target.set(newState) + override fun Observer.get(): T = target()() + } + + fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, + ): State = + object : State { + val referenceHolder = ReferenceHolderImpl() // keep this alive for at least as long as the returned state + val derivedState = mutableStateOf(initialValue) + init { + builder(referenceHolder, derivedState) + } + + override fun Observer.get(): T = derivedState() + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt new file mode 100644 index 00000000..4bb1efcd --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/basic/impl.kt @@ -0,0 +1,312 @@ +package gg.essential.elementa.state.v2.impl.basic + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ObserverImpl +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Semi-lazy node graph implementation. + * + * The actual code is extremely similar to [gg.essential.elementa.state.v2.impl.minimal.MarkThenPullImpl] (literally + * only a single line difference), however the mechanism by which it functions is not. + * The code has been duplicated, so we continue to have a simple reference implementation even when this implementation + * evolves further. + * + * This implementation operates in three phases: + * - The first phase propagates a may-be-dirty state to all potentially affected nodes + * - The second phase goes through all dirty nodes and run the third phase for each of them + * - The phase phase checks if the given node needs to be updated, recursively. And if so, updates it, marks all its + * direct dependents as dirty (to be processed by the second phase), and then returns to the second phase. + * + * Unlike [gg.essential.elementa.state.v2.impl.minimal.MarkThenPullImpl], this means that sub-graphs which are + * potentially affected but whose dependencies have not actually changed, will not be visited (more than once per + * them actually changing; as opposed to having to re-visit every time they are potentially affected). + * That does mean that this implementation will in exchange potentially visit intermediate nodes which do not actually + * have any effects attached to them any more (hence it only being "semi lazy"). + * However, in practice, non-affected nodes usually vastly outnumber dead intermediate nodes (especially because + * those are usually garbage collected together with the respective effects that used them) by one to two orders of + * magnitude, making this well worth it. + */ +internal object MarkThenPushAndPullImpl : Impl { + override fun mutableState(value: T): MutableState { + val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) + return object : State by node, MutableState { + override fun set(mapper: (T) -> T) { + node.set(mapper(node.getUntracked())) + } + } + } + + override fun memo(func: Observer.() -> T): State = + Node(NodeKind.Memo, NodeState.Dirty, func, null) + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + val node = Node(NodeKind.Effect, NodeState.Dirty, func, Unit) + node.update(Update.get()) + val refCleanup = referenceHolder.holdOnto(node) + return { + node.cleanup() + refCleanup() + } + } + +} + +private enum class NodeKind { + /** + * A leaf node which represents a manually updated value which only changes when [Node.set] is invoked. + * It does not have any dependencies nor a [Node.func]. + */ + Mutable, + + /** + * An intermediate node which is lazily computed and lazily updated via [Node.func]. + * May have any number of both dependencies and dependents. + */ + Memo, + + /** + * A node which represents the root of a dependency tree. + * It does not have any dependents and does not produce any value. + * + * Unlike [Memo], it is not lazy and will be updated when any of its dependencies change. + * If any of its dependencies are lazy, they too will be updated as necessary for this node to obtain a complete + * view of up-to-date values. + */ + Effect, +} + +private enum class NodeState { + /** + * The [Node.value] is up-to-date. + * For [NodeKind.Effect], the [Node.func] has been run with the latest values. + */ + Clean, + + /** + * Some of the node's dependencies, including transitive one, may be [Dirty] and need to be checked. + */ + ToBeChecked, + + /** + * The [Node.value] is outdated and needs to be re-evaluated. + * For [NodeKind.Effect], the [Node.func] needs to be re-run. + */ + Dirty, + + /** + * The node has been disposed off and should no longer be updated. + */ + Dead, +} + +private class Node( + val kind: NodeKind, + private var state: NodeState, + private val func: Observer.() -> T, + private var value: T?, +) : State, Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this + + private val observed = mutableSetOf>() + private val dependencies = mutableListOf>() + private val dependents: MutableList>> = mutableListOf() + + override fun Observer.get(): T { + return getTracked(this@get) + } + + fun getTracked(observer: Observer): T { + val impl = observer.observerImpl + if (impl is Node<*>) { + impl.observed.add(this) + } + return getUntracked() + } + + override fun getUntracked(): T { + if (state != NodeState.Clean) { + update(Update.get()) + } + @Suppress("UNCHECKED_CAST") + return value as T + } + + fun set(newValue: T) { + assert(kind == NodeKind.Mutable) + + if (value == newValue) { + return + } + + value = newValue + + val update = Update.get() + for (dep in dependents.iter()) { + dep.markDirty(update) + } + update.flush() + } + + private fun mark(update: Update, newState: NodeState) { + val oldState = state + if (oldState.ordinal >= newState.ordinal) { + return + } + + if (newState == NodeState.Dirty) { + update.queueNode(this) + } + + state = newState + } + + private fun markDirty(update: Update) { + mark(update, NodeState.Dirty) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + private fun markToBeChecked(update: Update) { + if (state != NodeState.Clean) return + + mark(update, NodeState.ToBeChecked) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + fun update(update: Update) { + if (state == NodeState.Clean) { + return + } + + if (state == NodeState.ToBeChecked) { + for (dep in dependencies) { + dep.update(update) + if (state == NodeState.Dirty) { + break + } + } + } + + if (state == NodeState.Dirty) { + val newValue = func(this) + + if (state == NodeState.Dead) { + return + } + + for (i in dependencies.indices.reversed()) { + val dep = dependencies[i] + if (dep !in observed) { + dependencies.removeAt(i) + dep.removeDependent(this) + } + } + for (dep in observed) { + if (dep !in dependencies) { + dependencies.add(dep) + dep.addDependent(this) + } + } + observed.clear() + + if (value != newValue) { + value = newValue + + for (dep in dependents.iter()) { + dep.mark(update, NodeState.Dirty) + } + } + } + + state = NodeState.Clean + } + + fun cleanup() { + for (dep in dependencies) { + dep.removeDependent(this) + } + dependencies.clear() + + state = NodeState.Dead + } + + private var referenceQueueField: ReferenceQueue>? = null + private val referenceQueue: ReferenceQueue> + get() = referenceQueueField ?: ReferenceQueue>().also { referenceQueueField = it } + + private fun addDependent(node: Node<*>) { + cleanupStaleReferences() + dependents.add(WeakReference(node, referenceQueue)) + } + + private fun removeDependent(node: Node<*>) { + val index = dependents.indexOfFirst { it.get() == node } + if (index >= 0) { + dependents.removeAt(index) + } + } + + private fun cleanupStaleReferences() { + val queue = referenceQueueField ?: return + + if (queue.poll() == null) { + return + } + + @Suppress("ControlFlowWithEmptyBody") + while (queue.poll() != null); + + dependents.removeIf { it.get() == null } + } + + private fun MutableList>>.iter(): Iterator> { + return asSequence().mapNotNull { it.get() }.iterator() + } +} + +private class Update { + private var queue: MutableList> = mutableListOf() + private var processing: Boolean = false + + fun queueNode(node: Node<*>) { + queue.add(node) + } + + fun flush() { + if (processing || queue.isEmpty()) { + return + } + + processing = true + try { + var i = 0 + while (true) { + val node = queue.getOrNull(i) ?: break + node.update(this) + i++ + } + queue.clear() + } finally { + processing = false + } + } + + companion object { + private val INSTANCE = ThreadLocal.withInitial { Update() } + fun get(): Update = INSTANCE.get() + } +} + +private val UNREACHABLE: Observer.() -> Nothing = { error("unreachable") } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt new file mode 100644 index 00000000..ae0d36e1 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/legacy/impl.kt @@ -0,0 +1,248 @@ +package gg.essential.elementa.state.v2.impl.legacy + + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.DelegatingMutableState +import gg.essential.elementa.state.v2.DelegatingState +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ObserverImpl +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** Legacy implementation based around `onSetValue` which makes no attempt at being glitch-free. */ +internal object LegacyImpl : Impl { + override fun mutableState(value: T): MutableState = BasicState(value) + + override fun memo(func: Observer.() -> T): State { + val subscribed = mutableMapOf, () -> Unit>() + val observed = mutableSetOf>() + val scope = LegacyObserverImpl(observed) + + return derivedState(initialValue = func(scope)) { owner, derivedState -> + fun updateSubscriptions() { + for (state in observed) { + if (state in subscribed) continue + + subscribed[state] = state.onSetValue(owner) { + val newValue = func(scope) + updateSubscriptions() + derivedState.set(newValue) + } + } + + subscribed.entries.removeAll { (state, unregister) -> + if (state !in observed) { + unregister() + true + } else { + false + } + } + + observed.clear() + } + updateSubscriptions() + } + } + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + var disposed = false + val release = referenceHolder.holdOnto(memo { + if (disposed) return@memo + func() + }) + return { + disposed = true + release() + } + } + + override fun stateDelegatingTo(state: State): DelegatingState = DelegatingStateImpl(state) + + override fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = DelegatingMutableStateImpl(state) + + override fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit + ): State = ReferenceHoldingBasicState(initialValue).apply { builder(this, this) } +} + +private class LegacyObserverImpl(val observed: MutableSet>) : Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this +} + +/** A simple implementation of [MutableState], containing only a backing field */ +private open class BasicState(private var valueBacker: T) : MutableState { + private val referenceQueue = ReferenceQueue() + private val listeners = mutableListOf>() + + /** + * Contains the size of the [listeners] list which we currently iterate over. + * We must not directly modify these entries as that may mess up the iteration, anything after those entries is fair + * game though. + * Additions always happen at the end of the list, so those are trivial. + * For removals we instead set the [ListenerEntry.removed] flag and let the iteration code clean up the entry when + * it passes over it. + * We can't solely rely on that for all cleanup because we only iterate the listener list when the value of the state + * changes, so if it doesn't, we need to clean up entries immediately. + */ + private var liveSize = 0 + + override fun Observer.get(): T { + (this@get.observerImpl as? LegacyObserverImpl)?.observed?.add(this@BasicState) + return getUntracked() + } + + override fun getUntracked(): T = valueBacker + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + return ListenerEntry(this, listener, ownerCallback).also { listeners.add(it) } + } + + override fun set(mapper: (T) -> T) { + val oldValue = valueBacker + val newValue = mapper(oldValue) + if (oldValue == newValue) { + return + } + + valueBacker = newValue + + // Iterate over listeners while allowing for concurrent add to the end of the list (newly added entries will not get + // called) and concurrent remove from anywhere in the list (via `removed` flag in each entry, or directly for newly + // added listeners). See [liveSize] docs. + liveSize = listeners.size + var i = 0 + while (i < liveSize) { + val entry = listeners[i] + if (entry.removed) { + listeners.removeAt(i) + liveSize-- + } else { + entry.get()?.invoke(newValue) + i++ + } + } + liveSize = 0 + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: BasicState, + listenerCallback: (T) -> Unit, + private val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + var removed = false + + override fun invoke() { + // If we do not currently iterate over the listener list, we can directly remove this entry from the list, + // otherwise we merely mark it as deleted and let the iteration code take care of it. + val index = state.listeners.indexOf(this@ListenerEntry) + if (index >= state.liveSize) { + state.listeners.removeAt(index) + } else { + removed = true + } + + ownerCallback.get()?.invoke() + } + } +} + +/** Base class for implementations of Delegating(Mutable)State classes. */ +private open class DelegatingStateBase>(protected var delegate: S) : State { + private val referenceQueue = ReferenceQueue() + private var listeners = mutableListOf>() + + override fun Observer.get(): T { + (this@get.observerImpl as? LegacyObserverImpl)?.observed?.add(this@DelegatingStateBase) + return getUntracked() + } + + override fun getUntracked(): T = delegate.get() + + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit { + cleanupStaleListeners() + val ownerCallback = WeakReference(owner.holdOnto(Pair(this, listener))) + val removeCallback = delegate.onSetValue(ReferenceHolder.Weak, listener) + return ListenerEntry(this, listener, removeCallback, ownerCallback).also { listeners.add(it) } + } + + + fun rebind(newState: S) { + val oldState = delegate + if (oldState == newState) { + return + } + + delegate = newState + + listeners = + listeners.mapNotNullTo(mutableListOf()) { entry -> + entry.removeCallback() + val listenerCallback = entry.get() ?: return@mapNotNullTo null + val removeCallback = newState.onSetValue(ReferenceHolder.Weak, listenerCallback) + ListenerEntry(this, listenerCallback, removeCallback, entry.ownerCallback) + } + + val oldValue = oldState.get() + val newValue = newState.get() + if (oldValue != newValue) { + listeners.forEach { it.get()?.invoke(newValue) } + } + } + + private fun cleanupStaleListeners() { + while (true) { + val reference = referenceQueue.poll() ?: break + (reference as ListenerEntry<*>).invoke() + } + } + + private class ListenerEntry( + private val state: DelegatingStateBase, + listenerCallback: (T) -> Unit, + val removeCallback: () -> Unit, + val ownerCallback: WeakReference<() -> Unit>, + ) : WeakReference<(T) -> Unit>(listenerCallback, state.referenceQueue), () -> Unit { + override fun invoke() { + state.listeners.remove(this@ListenerEntry) + removeCallback() + ownerCallback.get()?.invoke() + } + } +} + +/** Default implementation of [DelegatingState] */ +private class DelegatingStateImpl(delegate: State) : + DelegatingStateBase>(delegate), DelegatingState + +/** Default implementation of [DelegatingMutableState] */ +private class DelegatingMutableStateImpl(delegate: MutableState) : + DelegatingStateBase>(delegate), DelegatingMutableState { + override fun set(mapper: (T) -> T) { + delegate.set(mapper) + } +} + +/** A [BasicState] which additionally implements [ReferenceHolder] */ +private class ReferenceHoldingBasicState(value: T) : BasicState(value), ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt new file mode 100644 index 00000000..3fbd5b2a --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/impl/minimal/impl.kt @@ -0,0 +1,305 @@ +package gg.essential.elementa.state.v2.impl.minimal + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.MutableState +import gg.essential.elementa.state.v2.Observer +import gg.essential.elementa.state.v2.ObserverImpl +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.impl.Impl +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +/** + * Minimal mark-then-pull-based node graph implementation. + * + * This implementation operates in two simple phases: + * - The first phase pushes the may-be-dirty state through the graph to all potentially affected nodes + * - The second phase goes through all potentially affected effect nodes and recursively checks if they need to be + * updated. + * + * This does make for a fully correct reference implementation. + * However it always needs to visit all potentially affected effects on every update. + * In particular if we have a large mutable state (like a list) which is then split off into many smaller ones (like its + * items), all effects attached to all of the smaller nodes need to be visited, even if only a single item was modified + * in the list. + * + * For a more performant algorithm, see [gg.essential.elementa.state.v2.impl.markpushpull.MarkThenPushAndPullImpl]. + */ +internal object MarkThenPullImpl : Impl { + override fun mutableState(value: T): MutableState { + val node = Node(NodeKind.Mutable, NodeState.Clean, UNREACHABLE, value) + return object : State by node, MutableState { + override fun set(mapper: (T) -> T) { + node.set(mapper(node.getUntracked())) + } + } + } + + override fun memo(func: Observer.() -> T): State = + Node(NodeKind.Memo, NodeState.Dirty, func, null) + + override fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit { + val node = Node(NodeKind.Effect, NodeState.Dirty, func, Unit) + node.update(Update.get()) + val refCleanup = referenceHolder.holdOnto(node) + return { + node.cleanup() + refCleanup() + } + } + +} + +private enum class NodeKind { + /** + * A leaf node which represents a manually updated value which only changes when [Node.set] is invoked. + * It does not have any dependencies nor a [Node.func]. + */ + Mutable, + + /** + * An intermediate node which is lazily computed and lazily updated via [Node.func]. + * May have any number of both dependencies and dependents. + */ + Memo, + + /** + * A node which represents the root of a dependency tree. + * It does not have any dependents and does not produce any value. + * + * Unlike [Memo], it is not lazy and will be updated when any of its dependencies change. + * If any of its dependencies are lazy, they too will be updated as necessary for this node to obtain a complete + * view of up-to-date values. + */ + Effect, +} + +private enum class NodeState { + /** + * The [Node.value] is up-to-date. + * For [NodeKind.Effect], the [Node.func] has been run with the latest values. + */ + Clean, + + /** + * Some of the node's dependencies, including transitive one, may be [Dirty] and need to be checked. + */ + ToBeChecked, + + /** + * The [Node.value] is outdated and needs to be re-evaluated. + * For [NodeKind.Effect], the [Node.func] needs to be re-run. + */ + Dirty, + + /** + * The node has been disposed off and should no longer be updated. + */ + Dead, +} + +private class Node( + val kind: NodeKind, + private var state: NodeState, + private val func: Observer.() -> T, + private var value: T?, +) : State, Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this + + private val observed = mutableSetOf>() + private val dependencies = mutableListOf>() + private val dependents: MutableList>> = mutableListOf() + + override fun Observer.get(): T { + return getTracked(this@get) + } + + fun getTracked(observer: Observer): T { + val impl = observer.observerImpl + if (impl is Node<*>) { + impl.observed.add(this) + } + return getUntracked() + } + + override fun getUntracked(): T { + if (state != NodeState.Clean) { + update(Update.get()) + } + @Suppress("UNCHECKED_CAST") + return value as T + } + + fun set(newValue: T) { + assert(kind == NodeKind.Mutable) + + if (value == newValue) { + return + } + + value = newValue + + val update = Update.get() + for (dep in dependents.iter()) { + dep.markDirty(update) + } + update.flush() + } + + private fun mark(update: Update, newState: NodeState) { + val oldState = state + if (oldState.ordinal >= newState.ordinal) { + return + } + + if (kind == NodeKind.Effect && oldState == NodeState.Clean) { + update.queueNode(this) + } + + state = newState + } + + private fun markDirty(update: Update) { + mark(update, NodeState.Dirty) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + private fun markToBeChecked(update: Update) { + if (state != NodeState.Clean) return + + mark(update, NodeState.ToBeChecked) + + for (dep in dependents.iter()) { + dep.markToBeChecked(update) + } + } + + fun update(update: Update) { + if (state == NodeState.Clean) { + return + } + + if (state == NodeState.ToBeChecked) { + for (dep in dependencies) { + dep.update(update) + if (state == NodeState.Dirty) { + break + } + } + } + + if (state == NodeState.Dirty) { + val newValue = func(this) + + if (state == NodeState.Dead) { + return + } + + for (i in dependencies.indices.reversed()) { + val dep = dependencies[i] + if (dep !in observed) { + dependencies.removeAt(i) + dep.removeDependent(this) + } + } + for (dep in observed) { + if (dep !in dependencies) { + dependencies.add(dep) + dep.addDependent(this) + } + } + observed.clear() + + if (value != newValue) { + value = newValue + + for (dep in dependents.iter()) { + dep.mark(update, NodeState.Dirty) + } + } + } + + state = NodeState.Clean + } + + fun cleanup() { + for (dep in dependencies) { + dep.removeDependent(this) + } + dependencies.clear() + + state = NodeState.Dead + } + + private var referenceQueueField: ReferenceQueue>? = null + private val referenceQueue: ReferenceQueue> + get() = referenceQueueField ?: ReferenceQueue>().also { referenceQueueField = it } + + private fun addDependent(node: Node<*>) { + cleanupStaleReferences() + dependents.add(WeakReference(node, referenceQueue)) + } + + private fun removeDependent(node: Node<*>) { + val index = dependents.indexOfFirst { it.get() == node } + if (index >= 0) { + dependents.removeAt(index) + } + } + + private fun cleanupStaleReferences() { + val queue = referenceQueueField ?: return + + if (queue.poll() == null) { + return + } + + @Suppress("ControlFlowWithEmptyBody") + while (queue.poll() != null); + + dependents.removeIf { it.get() == null } + } + + private fun MutableList>>.iter(): Iterator> { + return asSequence().mapNotNull { it.get() }.iterator() + } +} + +private class Update { + private var queue: MutableList> = mutableListOf() + private var processing: Boolean = false + + fun queueNode(node: Node<*>) { + queue.add(node) + } + + fun flush() { + if (processing || queue.isEmpty()) { + return + } + + processing = true + try { + var i = 0 + while (true) { + val node = queue.getOrNull(i) ?: break + node.update(this) + i++ + } + queue.clear() + } finally { + processing = false + } + } + + companion object { + private val INSTANCE = ThreadLocal.withInitial { Update() } + fun get(): Update = INSTANCE.get() + } +} + +private val UNREACHABLE: Observer.() -> Nothing = { error("unreachable") } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt new file mode 100644 index 00000000..f5925f17 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/list.kt @@ -0,0 +1,55 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.TrackedList + +typealias ListState = State> +typealias MutableListState = MutableState> + +fun State>.toListState(): ListState { + var oldList = MutableTrackedList() + return memo { + val newList = get() + oldList.applyChanges(TrackedList.Change.estimate(oldList, newList)).also { oldList = it } + } +} + +fun ListState.mapChanges(init: (TrackedList) -> U, update: (old: U, changes: Sequence>) -> U): State { + var trackedList: TrackedList? = null + var trackedValue: U? = null + return memo { + val newList = get() + val oldList = trackedList + val newValue = + if (oldList == null) { + init(newList) + } else { + @Suppress("UNCHECKED_CAST") + update(trackedValue as U, newList.getChangesSince(oldList)) + } + + trackedList = newList + trackedValue = newValue + + newValue + } +} + +fun ListState.mapChange(init: (TrackedList) -> U, update: (old: U, change: TrackedList.Change) -> U): State = + mapChanges(init) { old, changes -> changes.fold(old, update) } + +fun listStateOf(vararg elements: T): ListState = + stateOf(MutableTrackedList(mutableListOf(*elements))) + +fun mutableListStateOf(vararg elements: T): MutableListState = + mutableStateOf(MutableTrackedList(mutableListOf(*elements))) + +fun MutableListState.set(index: Int, element: T) = set { it.set(index, element) } +fun MutableListState.setAll(newList: List) = set { it.applyChanges(TrackedList.Change.estimate(it, newList)) } +fun MutableListState.add(element: T) = set { it.add(element) } +fun MutableListState.add(index: Int, element: T) = set { it.add(index, element) } +fun MutableListState.addAll(elements: List) = set { it.addAll(elements) } +fun MutableListState.remove(element: T) = set { it.remove(element) } +fun MutableListState.removeAt(index: Int) = set { it.removeAt(index) } +fun MutableListState.removeAll(predicate: (T) -> Boolean) = set { it.removeAll(it.filter(predicate)) } +fun MutableListState.clear() = set { it.clear() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt new file mode 100644 index 00000000..1d036a7b --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/listCombinators.kt @@ -0,0 +1,139 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.MutableTrackedSet +import gg.essential.elementa.state.v2.collections.TrackedList +import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.combinators.zip + +fun ListState.toSet(): SetState { + val count = mutableMapOf() + return mapChange({ list -> + for (element in list) { + count.compute(element) { _, c -> (c ?: 0) + 1 } + } + MutableTrackedSet(list.toMutableSet()) + }, { set, change -> + when (change) { + is TrackedList.Add -> { + if (count.compute(change.element.value) { _, c -> (c ?: 0) + 1 } == 1) { + set.add(change.element.value) + } else { + set + } + } + is TrackedList.Remove -> { + if (count.compute(change.element.value) { _, c -> (c!! - 1).takeUnless { it == 0 } } == null) { + set.remove(change.element.value) + } else { + set + } + } + is TrackedList.Clear -> set.clear() + } + }) +} + +// mapList { it.filter(filter) } +fun ListState.filter(filter: (T) -> Boolean): ListState { + val indices = mutableListOf() + return mapChange({ list -> + MutableTrackedList(mutableListOf().also { filteredList -> + for (elem in list) { + if (filter(elem)) { + indices.add(filteredList.size) + filteredList.add(elem) + } else { + indices.add(-1) + } + } + }) + }) { list, change -> + when (change) { + is TrackedList.Add -> { + if (filter(change.element.value)) { + val mappedIndex = if (change.element.index == indices.size) { + // Fast path, add to end + list.size + } else { + // Slow path, to find the index of the newly added element, we need to find the index + // of the previous (non-filtered) element + var mappedIndex = 0 + for (i in (0 until change.element.index).reversed()) { + val index = indices[i] + if (index != -1) { + mappedIndex = index + 1 + break + } + } + // And then also increment the index of all elements that are after it + for (i in change.element.index .. indices.lastIndex) { + val index = indices[i] + if (index != -1) { + indices[i] = index + 1 + } + } + mappedIndex + } + indices.add(change.element.index, mappedIndex) + list.add(mappedIndex, change.element.value) + } else { + indices.add(change.element.index, -1) + list + } + } + is TrackedList.Remove -> { + val mappedIndex = indices.removeAt(change.element.index) + if (mappedIndex != -1) { + for (i in change.element.index .. indices.lastIndex) { + val index = indices[i] + if (index != -1) { + indices[i] = index - 1 + } + } + list.removeAt(mappedIndex) + } else { + list + } + } + is TrackedList.Clear -> { + indices.clear() + list.clear() + } + } + } +} + +// mapList { it.map(mapper) } +fun ListState.mapEach(mapper: (T) -> U): ListState = + mapChange({ MutableTrackedList(it.mapTo(mutableListOf(), mapper)) }) { list, change -> + when (change) { + is TrackedList.Add -> list.add(change.element.index, mapper(change.element.value)) + is TrackedList.Remove -> list.removeAt(change.element.index) + is TrackedList.Clear -> list.clear() + } + } + + +// TODO: all of these are based on mapList and as such are quite inefficient, might make sense to implement some as efficient primitives instead + +fun ListState.mapList(mapper: (List) -> List): ListState = + map(mapper).toListState() + +fun ListState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = + zip(otherState) { list, other -> list.map { transform(it, other) } }.toListState() + +fun ListState.zipElements(otherList: ListState, transform: (T, U) -> V) = + zip(otherList) { a, b -> a.zip(b, transform) }.toListState() + +fun ListState.mapEachNotNull(mapper: (T) -> U?) = mapList { it.mapNotNull(mapper) } + +fun ListState.filterNotNull() = mapList { it.filterNotNull() } + +inline fun ListState<*>.filterIsInstance(): ListState = map { it.filterIsInstance() }.toListState() + +fun ListState.flatMap(block: (T) -> Iterable) = mapList { it.flatMap(block) } + +fun ListState.isEmpty() = map { it.isEmpty() } + +fun ListState.isNotEmpty() = map { it.isNotEmpty() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt new file mode 100644 index 00000000..afa981cb --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/set.kt @@ -0,0 +1,47 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.* + +typealias SetState = State> +typealias MutableSetState = MutableState> + +fun State>.toSetState(): SetState { + var oldSet = MutableTrackedSet() + return memo { + val newSet = get() + oldSet.applyChanges(TrackedSet.Change.estimate(oldSet, newSet)).also { oldSet = it } + } +} + +fun SetState.mapChanges(init: (TrackedSet) -> U, update: (old: U, changes: Sequence>) -> U): State { + var trackedSet: TrackedSet? = null + var trackedValue: U? = null + return memo { + val newSet = get() + val oldSet = trackedSet + val newValue = + if (oldSet == null) { + init(newSet) + } else { + @Suppress("UNCHECKED_CAST") + update(trackedValue as U, newSet.getChangesSince(oldSet)) + } + + trackedSet = newSet + trackedValue = newValue + + newValue + } +} + +fun SetState.mapChange(init: (TrackedSet) -> U, update: (old: U, change: TrackedSet.Change) -> U): State = + mapChanges(init) { old, changes -> changes.fold(old, update) } + +fun mutableSetState(vararg elements: T): MutableSetState = + mutableStateOf(MutableTrackedSet(mutableSetOf(*elements))) + +fun MutableSetState.add(element: T) = set { it.add(element) } +fun MutableSetState.addAll(toAdd: Collection) = set { it.addAll(toAdd) } +fun MutableSetState.setAll(newSet: Set) = set { it.applyChanges(TrackedSet.Change.estimate(it, newSet)) } +fun MutableSetState.remove(element: T) = set { it.remove(element) } +fun MutableSetState.clear() = set { it.clear() } diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt new file mode 100644 index 00000000..21165f35 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/setCombinators.kt @@ -0,0 +1,81 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.collections.MutableTrackedList +import gg.essential.elementa.state.v2.collections.MutableTrackedSet +import gg.essential.elementa.state.v2.collections.TrackedSet +import gg.essential.elementa.state.v2.combinators.map +import gg.essential.elementa.state.v2.combinators.zip + +fun SetState.toList(): ListState { + return mapChange({ MutableTrackedList(it.toMutableList()) }, { list, change -> + when (change) { + is TrackedSet.Add -> list.add(change.element) + is TrackedSet.Remove -> list.remove(change.element) + is TrackedSet.Clear -> list.clear() + } + }) +} + +fun SetState.filter(filter: (T) -> Boolean): SetState = + mapChange({ MutableTrackedSet(it.filterTo(mutableSetOf(), filter)) }) { set, change -> + when (change) { + is TrackedSet.Add -> { + if (filter(change.element)) { + set.add(change.element) + } else { + set + } + } + is TrackedSet.Remove -> set.remove(change.element) + is TrackedSet.Clear -> set.clear() + } + } + +fun SetState.mapEach(mapper: (T) -> U): SetState { + val mappedValues = mutableMapOf() + val mappedCount = mutableMapOf() + return mapChange({ set -> + MutableTrackedSet(set.mapTo(mutableSetOf()) { value -> + mapper(value).also { mappedValue -> + mappedValues[value] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1} + } + }) + }) { list, change -> + when (change) { + is TrackedSet.Add -> { + val mappedValue = mapper(change.element) + mappedValues[change.element] = mappedValue + mappedCount.compute(mappedValue) { _, i -> (i ?: 0) + 1 } + list.add(mappedValue) + } + is TrackedSet.Remove -> { + val mappedValue = mappedValues.remove(change.element)!! + if (mappedCount.computeIfPresent(mappedValue) { _, i -> (i - 1).takeIf { i > 0 } } == null) { + list.remove(mappedValue) + } else { + list + } + } + is TrackedSet.Clear -> { + mappedValues.clear() + mappedCount.clear() + list.clear() + } + } + } +} + +// TODO: all of these are based on mapSet and as such are quite inefficient, might make sense to implement some as efficient primitives instead + +fun SetState.mapSet(mapper: (Set) -> Set): SetState = + map(mapper).toSetState() + +fun SetState.zipWithEachElement(otherState: State, transform: (T, U) -> V) = + zip(otherState) { set, other -> set.mapTo(mutableSetOf()) { transform(it, other) } }.toSetState() + +fun SetState.mapEachNotNull(mapper: (T) -> U?) = mapSet { it.mapNotNullTo(mutableSetOf(), mapper) } + +fun SetState.filterNotNull() = mapSet { it.filterNotNullTo(mutableSetOf()) } + +inline fun SetState<*>.filterIsInstance(): SetState = map { it.filterIsInstanceTo>(mutableSetOf()) }.toSetState() diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt new file mode 100644 index 00000000..46f23e61 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/state.kt @@ -0,0 +1,313 @@ +package gg.essential.elementa.state.v2 + +import gg.essential.elementa.state.v2.ReferenceHolder +import gg.essential.elementa.state.v2.impl.Impl +import gg.essential.elementa.state.v2.impl.basic.MarkThenPushAndPullImpl + +private val impl: Impl = MarkThenPushAndPullImpl + +/** + * Note: This interface must not be implemented by user code. The State implementation may cast it to its internal + * implementation type without checking. + */ +interface ObserverImpl + +/** + * A marker interface for an object which may observe which states are being accessed, such that it can then subscribe + * to these states to be updated when they change. + * + * Note that the duration during which a given [Observer] can be used is usually limited to the call in which it was + * received. + * It should not be stored (neither in a field, nor implicitly in an asynchronous lambda) and then used at a later time. + */ +interface Observer { + val observerImpl: ObserverImpl + + /** + * Get the current value of the State object and subscribe the observer to be re-evaluated when it changes. + */ + operator fun State.invoke(): T = with(this@Observer) { get() } +} + +/** + * An [Observer] which does not track accesses. + * + * May be used to evaluate a method which requires an [Observer] once to get the current value when you do not care + * about future changes. + * To get the current value of a [State], one can also use the [State.getUntracked] shortcut. + */ +object Untracked : Observer, ObserverImpl { + override val observerImpl: ObserverImpl + get() = this +} + +/** + * Creates a [State] which lazily computes its value via the given pure function [func] and caches the result until + * one of the observed dependencies changes. + * + * You **MUST NOT** use [memo] when [func] triggers any side effects; there are no guarantees for when or even how often + * [func] is called (it is however guaranteed to always see a consistent view of all other [State]s). + * To have an external system react to changes in the State system (i.e. for it to "have an effect"), use [effect]. + * + * The two main use cases for [memo] are: + * - [func] represents a non-trivial / expensive computation which you do not want to re-evaluate on each access + * - [func] simplifies its dependencies (e.g. picks one item out of a list) and you do not want its dependents to + * unnecessarily be re-evaluated even though the simplified value is unchanged (e.g. whenever any other entry in the + * list is changed) + * + * If neither of the above applies, consider simply creating a custom [State] implementation which computes your [func] + * every time its [State.get] is called (e.g. instead of `memo { myState() + 1 }` write `State { myState() + 1 }`). + * Doing so has significantly lower overhead (just the cost of a single lambda) than [memo]. + */ +fun memo(func: Observer.() -> T): State = impl.memo(func) + +/** + * Creates a [State] which lazily [get][State.get]s its value from `this` State and caches the result until one of the + * observed dependencies changes. + * May return `this` if it is already such a State. + * + * Semantically `State { func() }.memo()` is equivalent to `memo { func() }`. + * + * @see [memo] + */ +fun State.memo(): State = memo inner@{ this@memo() } + +/** + * Runs the given function [func] once immediately and whenever any of the [State]s it [observes][Observer] change. + * + * A "cleanup" function is returned which when invoked will unregister the effect, such that it will no longer be called + * thereafter. + * + * Hint: If a [State] you wish to use often has unrelated changes you do not care about, consider breaking it down into + * a smaller [State] ahead of time using [memo]. + * + * ### Lifetime + * + * The effect registration is weak by default. + * This means that it may be garbage collected if no other strong references to the returned function exist. + * Once an effect is garbage collected, it will (obviously) no longer be called. + * + * Keeping a strong reference to the returned function is easy to forget, so this method requires you + * to explicitly pass in an object which will maintain a strong reference to it for you. + * With that, your effect will stay active **at least** as long as the given [owner] is alive (unless the returned + * function is explicitly invoked, in which case it ceases operation immediately). + * + * In general, the lifetime of your effect should match the lifetime of the passed [owner], usually the thing + * (e.g. [UIComponent]) the effect is modifying. + * If the owner far outlives your effect, you may be unnecessarily running your effect and leaking memory because owner + * will keep all those effects and anything they reference alive far beyond the point where they are needed. + * If your effect outlives the owner, then it may become inactive sooner than you expected and whatever it is + * updating might no longer update properly. + * + * If you wish to manually keep your effect alive (by holding on to the returned function), pass [ReferenceHolder.Weak] + * as the owner. + * + * ### Recursion + * + * You should avoid calling [MutableState.set] from the given function. + * + * While the State system does support recursion, such nested state changes cannot be performed atomically and as such + * it is very much possible that another [effect] has already observed both the value that trigger your [effect] but + * also the old value of the state you want to update; + * it will then be invoked again which, depending on what it does, may have unintended consequences. + * + * To have the value of [State] depend on one or more other [State]s, use [memo] to create it. + */ +fun effect(referenceHolder: ReferenceHolder, func: Observer.() -> Unit): () -> Unit = impl.effect(referenceHolder, func) + +/** + * Runs the given function [func] whenever the value of `this` State changes. + * + * See [effect] for details. + */ +fun State.onChange(referenceHolder: ReferenceHolder, func: Observer.(value: T) -> Unit): () -> Unit { + var first = true + return effect(referenceHolder) { + val value = this@onChange() + if (first) { + first = false + } else { + func(value) + } + } +} + +/** + * The base for all Elementa State objects. + * + * State objects are essentially just a wrapper around a (potentially computed) value with the ability to subscribe to + * changes. + * + * The primary advantage of using state is that a single state object can be shared between multiple + * components or constraints as well as re-used and combined to derive other State from it. + * All in a declarative way, i.e. no need to manually go and remember to update every piece of GUI, you only update + * the base [MutableState] instance, and everyone who cares will have subscribed (directly or indirectly) and + * automatically be updated accordingly. + * + * This allows one value update to be seen by multiple components or constraints. + * For example, if a component has many text children, and they all share the same + * color state variable, then whenever the value of the state object is updated, all of the text + * components will instantly change color. + * + * State also composes well, e.g. a function which returns a `State` for whether a component is hovered can + * easily be mapped to one or more `State` (potentially taking into account other state too) which can then be + * used to color the background/outline/etc. of the same or other components. + * + * The most important primitives of the State system: + * - To create a simple [MutableState] which can be updated manually, use [mutableStateOf]. + * - To create [State] which derives its value from other [State], use [memo] or a custom [State] implementation (see + * the documentation on the former for details). + * - To make external systems react to [State] changes, use [effect]. + * + * The Elementa State system also provides a bunch of more subtle functionality that may not be apparent at first + * glance. E.g. it will allow state and effect nodes to be be garbage collected when they are no longer needed, and it + * will generally guarantee that all views of the State system are consistent, i.e. when there are states derived from + * other states, you'll either see the old value of all of them, or the updated values for all of them, but never an + * inconsistent mix of the two. + * + * Those readers familiar with other reactive/signal libraries (e.g. SolidJS, Leptos, Angular, MobX) may notice + * many similarities to these because [State] is pretty much Elementa's solution to the same set of problems. + */ +fun interface State { + /** + * Get the current value of this State object and subscribe the observer to be re-evaluated when it changes. + */ + fun Observer.get(): T + + /** + * Get the current value of this State object. + */ + fun getUntracked(): T = with(Untracked) { get() } + + /** Get the value of this State object */ + @Deprecated("Calls to this method are not tracked. If this is intentional, use `getUntracked` instead.") + fun get(): T = getUntracked() + + /** + * Register a listener which will be called whenever the value of this State object changes + * + * The listener registration is weak by default. This means that no strong reference to the + * listener is kept in this State object and your listener may be garbage collected if no other + * strong references to it exist. Once a listener is garbage collected, it will (obviously) no + * longer receive updates. + * + * Keeping a strong reference to your own listener is easy to forget, so this method requires you + * to explicitly pass in an object which will maintain a strong reference to your listener for + * you. With that, your listener will stay active **at least** as long as the given [owner] is + * alive (unless the returned callback in invoked). + * + * In general, the lifetime of your listener should match the lifetime of the passed [owner], + * usually the thing (e.g. [UIComponent]) the listener is modifying. If the owner far outlives + * your listener, you may be leaking memory because the owner will keep all those listeners and + * anything they reference alive far beyond the point where they are needed. If your listener + * outlives the owner, then it may become inactive sooner than you expected and whatever it is + * updating might no longer update properly. + * + * If you wish to manually keep your listener alive, pass [ReferenceHolder.Weak] as the owner. + * + * @return A callback which, when invoked, removes this listener + */ + @Deprecated("If this method is used to update dependent states, use `stateBy` instead.\n" + + "Otherwise the State system cannot be guaranteed that downsteam states have a consistent view of upstream" + + "values (i.e. so called \"glitches\" may occur) and all dependences will be forced to evaluate eagerly" + + "instead of the usual lazy behavior (where states are only updated if there is a consumer).\n" + + "\n" + + "If this method is used to drive a final effect (e.g. updating some non-State UI property), and you also" + + "care about the initial value of the state, consider using `effect` instead.\n" + + "If you really only care about changes and not the inital value, use `onChange`.") + fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = onChange(owner) { listener(it) } +} + +/* ReferenceHolder is defined in Elementa as: +/** + * Holds strong references to listeners to prevent them from being garbage collected. + * @see State.onSetValue + */ +interface ReferenceHolder { + fun holdOnto(listener: Any): () -> Unit + + object Weak : ReferenceHolder { + override fun holdOnto(listener: Any): () -> Unit = {} + } +} + */ + +/** A [State] with a value that can be changed via [set] */ +@JvmDefaultWithoutCompatibility +interface MutableState : State { + /** + * Update the value of this State object. + * + * After the value has been updated, all listeners of this State object are notified. + * + * The provided lambda must be a pure function which will return the new value for this State give + * the current value. + * + * Note that while most basic State implementations will call the lambda and notify listeners + * immediately, there is no general requirement for them to do so, and specialized State + * implementations may delay either or both to e.g. batch multiple updates together. + */ + fun set(mapper: (T) -> T) + + /** + * Update the value of this State object. + * + * After the value has been updated, all listeners of this State object are notified. + * + * Note that while most basic State implementations will update and notify listeners immediately, + * there is no general requirement for them to do so, and specialized State implementations may + * delay either or both to e.g. batch multiple updates together. + * + * @see [set] + */ + fun set(value: T) = set { value } +} + +/** A [State] delegating to a configurable target [State] */ +interface DelegatingState : State { + fun rebind(newState: State) +} + +/** A [MutableState] delegating to a configurable target [MutableState] */ +@JvmDefaultWithoutCompatibility +interface DelegatingMutableState : MutableState { + fun rebind(newState: MutableState) +} + +/** Creates a new [State] with the given value. */ +fun stateOf(value: T): State = ImmutableState(value) + +/** Creates a new [MutableState] with the given initial value. */ +fun mutableStateOf(value: T): MutableState = impl.mutableState(value) + +/** Creates a new [DelegatingState] with the given target [State]. */ +fun stateDelegatingTo(state: State): DelegatingState = impl.stateDelegatingTo(state) + +/** Creates a new [DelegatingMutableState] with the given target [MutableState]. */ +fun mutableStateDelegatingTo(state: MutableState): DelegatingMutableState = + impl.mutableStateDelegatingTo(state) + +/** Creates a [State] which derives its value in a user-defined way from one or more other states */ +@Deprecated("See `State.onSetValue`. Use `stateBy` instead.") +fun derivedState( + initialValue: T, + builder: (owner: ReferenceHolder, derivedState: MutableState) -> Unit, +): State = impl.derivedState(initialValue, builder) + +/** A simple, immutable implementation of [State] */ +private class ImmutableState(private val value: T) : State { + override fun get(): T = value + override fun onSetValue(owner: ReferenceHolder, listener: (T) -> Unit): () -> Unit = {} + override fun Observer.get(): T = value + override fun getUntracked(): T = value +} + +/** A simple implementation of [ReferenceHolder] */ +class ReferenceHolderImpl : ReferenceHolder { + private val heldReferences = mutableListOf() + + override fun holdOnto(listener: Any): () -> Unit { + heldReferences.add(listener) + return { heldReferences.remove(listener) } + } +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt new file mode 100644 index 00000000..62ad1da6 --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/stateBy.kt @@ -0,0 +1,23 @@ +package gg.essential.elementa.state.v2 + +/** + * Creates a state that derives its value using the given [block]. The value of any state may be accessed within this + * block via [StateByScope.invoke]. These accesses are tracked and the block is automatically re-evaluated whenever any + * one of them changes. + */ +@Deprecated("Use `memo` (result is cached) or `State` lambda (result is not cached)") +fun stateBy(block: StateByScope.() -> T): State { + return memo { + val scope = object : StateByScope { + override fun State.invoke(): T { + return with(this@memo) { get() } + } + } + block(scope) + } +} + +@Deprecated("Superseded by `Observer`") +interface StateByScope { + operator fun State.invoke(): T +} diff --git a/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt new file mode 100644 index 00000000..87e9ad9f --- /dev/null +++ b/unstable/statev2/src/main/kotlin/gg/essential/elementa/state/v2/utils/completableFuture.kt @@ -0,0 +1,20 @@ +package gg.essential.elementa.state.v2.utils + +import gg.essential.elementa.state.v2.State +import gg.essential.elementa.state.v2.mutableStateOf +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor + +fun CompletableFuture.toState(mainThreadExecutor: Executor): State { + if (isDone) { + return State { get() } + } + + val resolved by lazy(LazyThreadSafetyMode.NONE) { + val resolved = mutableStateOf(null) + thenAcceptAsync({ resolved.set(it) }, mainThreadExecutor) + resolved + } + + return State { if (isDone) get() else resolved() } +} diff --git a/versions/1.12.2-forge/.gitkeep b/versions/1.12.2-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.15.2-forge/.gitkeep b/versions/1.15.2-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.16.2-fabric/.gitkeep b/versions/1.16.2-fabric/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.16.2-forge/.gitkeep b/versions/1.16.2-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/1.8.9-forge/.gitkeep b/versions/1.8.9-forge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/versions/api/platform.api b/versions/api/platform.api deleted file mode 100644 index 6f3cc9cf..00000000 --- a/versions/api/platform.api +++ /dev/null @@ -1,19 +0,0 @@ -public final class gg/essential/elementa/dsl/UtilitiesKt_platform { - @1.8.9-forge - public static final fun width (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;)F - @1.8.9-forge - public static synthetic fun width$default (Lnet/minecraft/util/IChatComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F - @1.17.1-forge,1.18.1-forge - public static final fun width (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;)F - @1.17.1-forge,1.18.1-forge - public static synthetic fun width$default (Lnet/minecraft/network/chat/Component;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F - @1.16.2-fabric,1.17.1-fabric,1.18.1-fabric - public static final fun width (Lnet/minecraft/text/Text;FLgg/essential/elementa/font/FontProvider;)F - @1.16.2-fabric,1.17.1-fabric,1.18.1-fabric - public static synthetic fun width$default (Lnet/minecraft/text/Text;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F - @1.12.2-forge,1.15.2-forge,1.16.2-forge - public static final fun width (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;)F - @1.12.2-forge,1.15.2-forge,1.16.2-forge - public static synthetic fun width$default (Lnet/minecraft/util/text/ITextComponent;FLgg/essential/elementa/font/FontProvider;ILjava/lang/Object;)F -} - diff --git a/versions/gradle.properties b/versions/gradle.properties deleted file mode 100644 index edcf861e..00000000 --- a/versions/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -baseArtifactId=elementa diff --git a/versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt b/versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt deleted file mode 100644 index 2d08f6f1..00000000 --- a/versions/src/main/java/gg/essential/elementa/dsl/utilities_platform.kt +++ /dev/null @@ -1,10 +0,0 @@ -@file:JvmName("UtilitiesKt_platform") -package gg.essential.elementa.dsl - -import gg.essential.elementa.font.DefaultFonts -import gg.essential.elementa.font.FontProvider -import gg.essential.universal.wrappers.message.UTextComponent -import net.minecraft.util.text.ITextComponent - -fun ITextComponent.width(textScale: Float = 1f, fontProvider: FontProvider = DefaultFonts.VANILLA_FONT_RENDERER) = - UTextComponent(this).formattedText.width(textScale, fontProvider) diff --git a/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java b/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java deleted file mode 100644 index 272ef877..00000000 --- a/versions/src/main/java/gg/essential/elementa/impl/PlatformImpl.java +++ /dev/null @@ -1,65 +0,0 @@ -package gg.essential.elementa.impl; - -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiScreen; -import net.minecraft.client.shader.Framebuffer; -import net.minecraft.util.ChatAllowedCharacters; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -@SuppressWarnings("unused") // instantiated via reflection from Platform.Companion -public class PlatformImpl implements Platform { - - @Override - public int getMcVersion() { - //#if MC==11801 - //$$ return 11801; - //#elseif MC==11701 - //$$ return 11701; - //#elseif MC==11602 - //$$ return 11602; - //#elseif MC==11502 - //$$ return 11502; - //#elseif MC==11202 - return 11202; - //#elseif MC==10809 - //$$ return 10809; - //#endif - } - - @Nullable - @Override - public Object getCurrentScreen() { - return Minecraft.getMinecraft().currentScreen; - } - - @Override - public void setCurrentScreen(@Nullable Object screen) { - Minecraft.getMinecraft().displayGuiScreen((GuiScreen) screen); - } - - @Override - public boolean isAllowedInChat(char c) { - return ChatAllowedCharacters.isAllowedCharacter(c); - } - - @Override - public void enableStencil() { - //#if MC<11500 - Framebuffer framebuffer = Minecraft.getMinecraft().getFramebuffer(); - if (!framebuffer.isStencilEnabled()) { - framebuffer.enableStencil(); - } - //#endif - } - - @Override - public boolean isCallingFromMinecraftThread() { - //#if MC>=11400 - //$$ return Minecraft.getInstance().isOnExecutionThread(); - //#else - return Minecraft.getMinecraft().isCallingFromMinecraftThread(); - //#endif - } -} diff --git a/versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform b/versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform deleted file mode 100644 index 16cfa331..00000000 --- a/versions/src/main/resources/META-INF/services/gg.essential.elementa.impl.Platform +++ /dev/null @@ -1 +0,0 @@ -gg.essential.elementa.impl.PlatformImpl