diff --git a/KotlinCore/build.gradle.kts b/KotlinCore/build.gradle.kts
index 8467d3ac..d3b98b47 100644
--- a/KotlinCore/build.gradle.kts
+++ b/KotlinCore/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
compileOnly(libs.paperapi)
compileOnly(project(":SpigotCore"))
+ implementation(libs.coroutinesCore)
implementation(libs.exposedCore)
implementation(libs.exposedDao)
implementation(libs.exposedJdbc)
@@ -47,4 +48,4 @@ val compileKotlin: KotlinCompile by tasks
compileKotlin.compilerOptions {
freeCompilerArgs.set(listOf("-XXLanguage:+ContextParameters"))
-}
\ No newline at end of file
+}
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/Observer.kt b/KotlinCore/src/de/steamwar/kotlin/ui/Observer.kt
deleted file mode 100644
index 6990e14a..00000000
--- a/KotlinCore/src/de/steamwar/kotlin/ui/Observer.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * This file is a part of the SteamWar software.
- *
- * Copyright (C) 2026 SteamWar.de-Serverteam
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-package de.steamwar.kotlin.ui
-
-import kotlin.properties.ReadOnlyProperty
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-abstract class Observable {
- internal val listeners = mutableSetOf()
-
- open fun removeListener(render: ObserverListener) {
- listeners.remove(render)
- }
-
- internal fun notifyListeners() {
- listeners.forEach { it.update() }
- }
-
- context(render: ObserverListener)
- fun listen(): Observable {
- listeners.add(render)
- render.observers.add(this)
- return this
- }
-
- fun listen(callback: (T) -> Unit): () -> Unit {
- val listener = object: ObserverListener() {
- override fun update() = callback(get())
- }
- listeners.add(listener)
- callback(get())
- return { removeListener(listener) }
- }
-
- abstract fun get(): T
-
- fun map(mapper: (T) -> R): Observable = Delegate(mapper, this)
-
- class Delegate(val mapper: (T) -> R, val parent: Observable): Observable(), ReadOnlyProperty {
- var value: R? = null
- var unsubParent = parent.listen {
- val old = value
- value = mapper(it)
- if (old != value) {
- notifyListeners()
- }
- }
-
- override fun removeListener(render: ObserverListener) {
- super.removeListener(render)
- if (listeners.isEmpty()) {
- unsubParent()
- }
- }
-
- override fun get(): R = value!!
-
- override fun getValue(thisRef: Any?, property: KProperty<*>): R = value!!
- }
-}
-
-class Observer(private var value: T): ReadWriteProperty, Observable() {
- override fun getValue(thisRef: Any?, property: KProperty<*>): T {
- return value
- }
-
- override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
- this.value = value
- notifyListeners()
- }
-
- fun set(value: T) {
- this.value = value
- notifyListeners()
- }
-
- override fun get() = value
-
- fun update(update: (T) -> T) {
- this.value = update(value)
- notifyListeners()
- }
-}
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/StateFlow.kt b/KotlinCore/src/de/steamwar/kotlin/ui/StateFlow.kt
new file mode 100644
index 00000000..4a4441ad
--- /dev/null
+++ b/KotlinCore/src/de/steamwar/kotlin/ui/StateFlow.kt
@@ -0,0 +1,90 @@
+/*
+ * This file is a part of the SteamWar software.
+ *
+ * Copyright (C) 2026 SteamWar.de-Serverteam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package de.steamwar.kotlin.ui
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlin.reflect.KProperty
+
+private val uiStateScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
+
+context(render: StateFlowListener)
+fun StateFlow.listen(): StateFlow {
+ render.track(this) {
+ drop(1).onEach { render.update() }.launchIn(uiStateScope)
+ }
+ return this
+}
+
+context(render: StateFlowListener)
+fun MutableStateFlow.listen(): MutableStateFlow {
+ render.track(this) {
+ drop(1).onEach { render.update() }.launchIn(uiStateScope)
+ }
+ return this
+}
+
+fun StateFlow.listen(callback: (T) -> Unit): () -> Unit {
+ callback(value)
+ val job = drop(1).onEach { callback(it) }.launchIn(uiStateScope)
+ return { job.cancel() }
+}
+
+operator fun StateFlow.getValue(thisRef: Any?, property: KProperty<*>): T = value
+
+operator fun MutableStateFlow.setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+ this.value = value
+}
+
+fun StateFlow.map(mapper: (T) -> R): StateFlow = MappedStateFlow(this, mapper)
+
+@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
+private class MappedStateFlow(
+ private val parent: StateFlow,
+ private val mapper: (T) -> R,
+): StateFlow {
+ override val replayCache: List
+ get() = listOf(value)
+
+ override val value: R
+ get() = mapper(parent.value)
+
+ override suspend fun collect(collector: FlowCollector): Nothing {
+ var initialized = false
+ var previous: Any? = null
+
+ parent.collect {
+ val mapped = mapper(it)
+ if (!initialized || previous != mapped) {
+ initialized = true
+ previous = mapped
+ collector.emit(mapped)
+ }
+ }
+ }
+}
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/ObserverListener.kt b/KotlinCore/src/de/steamwar/kotlin/ui/StateFlowListener.kt
similarity index 69%
rename from KotlinCore/src/de/steamwar/kotlin/ui/ObserverListener.kt
rename to KotlinCore/src/de/steamwar/kotlin/ui/StateFlowListener.kt
index d407f08c..8c609ed7 100644
--- a/KotlinCore/src/de/steamwar/kotlin/ui/ObserverListener.kt
+++ b/KotlinCore/src/de/steamwar/kotlin/ui/StateFlowListener.kt
@@ -19,13 +19,23 @@
package de.steamwar.kotlin.ui
-abstract class ObserverListener {
- val observers = mutableSetOf>()
+import kotlinx.coroutines.Job
+
+abstract class StateFlowListener {
+ private val jobs = mutableMapOf()
+
+ internal fun track(key: Any, createJob: () -> Job) {
+ if (key in jobs) return
+
+ val job = createJob()
+ jobs[key] = job
+ job.invokeOnCompletion { jobs.remove(key) }
+ }
abstract fun update()
open fun destroy() {
- observers.forEach { it.removeListener(this) }
- observers.clear()
+ jobs.values.toList().forEach { it.cancel() }
+ jobs.clear()
}
-}
\ No newline at end of file
+}
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/TestInv.kt b/KotlinCore/src/de/steamwar/kotlin/ui/TestInv.kt
new file mode 100644
index 00000000..6454ee69
--- /dev/null
+++ b/KotlinCore/src/de/steamwar/kotlin/ui/TestInv.kt
@@ -0,0 +1,46 @@
+/*
+ * This file is a part of the SteamWar software.
+ *
+ * Copyright (C) 2026 SteamWar.de-Serverteam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package de.steamwar.kotlin.ui
+
+import de.steamwar.kotlin.ui.components.item
+import de.steamwar.kotlin.util.count
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.bukkit.Material
+import org.bukkit.entity.Player
+import org.bukkit.inventory.ItemStack
+
+val Counter = MutableStateFlow(0)
+
+class TestInv(player: Player): UIInventory(player) {
+ override fun view() {
+ inventory(3, "") {
+ item {
+ val amount by Counter.map { it + 1 }.listen()
+ item = ItemStack.of(Material.STONE)
+ .count(amount)
+ x = 0
+ y = 0
+ onClick {
+ Counter.value += 1
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/UIInventory.kt b/KotlinCore/src/de/steamwar/kotlin/ui/UIInventory.kt
index dba7178b..164d617d 100644
--- a/KotlinCore/src/de/steamwar/kotlin/ui/UIInventory.kt
+++ b/KotlinCore/src/de/steamwar/kotlin/ui/UIInventory.kt
@@ -23,7 +23,7 @@ import de.steamwar.kotlin.ui.context.WindowContext
import org.bukkit.entity.Player
import org.bukkit.event.inventory.InventoryType
-abstract class UIInventory(val player: Player): ObserverListener() {
+abstract class UIInventory(val player: Player): StateFlowListener() {
var window: UIWindow? = null
abstract fun view()
@@ -52,4 +52,4 @@ abstract class UIInventory(val player: Player): ObserverListener() {
protected fun inventory(type: InventoryType, title: String, init: WindowContext.() -> Unit) {
window = UIWindow(type, title, player, init)
}
-}
\ No newline at end of file
+}
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/components/ItemContext.kt b/KotlinCore/src/de/steamwar/kotlin/ui/components/ItemContext.kt
index 3609e979..a8660b0c 100644
--- a/KotlinCore/src/de/steamwar/kotlin/ui/components/ItemContext.kt
+++ b/KotlinCore/src/de/steamwar/kotlin/ui/components/ItemContext.kt
@@ -19,7 +19,7 @@
package de.steamwar.kotlin.ui.components
-import de.steamwar.kotlin.ui.ObserverListener
+import de.steamwar.kotlin.ui.StateFlowListener
import de.steamwar.kotlin.ui.RenderMarker
import de.steamwar.kotlin.ui.RenderObject
import de.steamwar.kotlin.ui.context.GroupContext
@@ -29,7 +29,7 @@ import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.inventory.ItemStack
@RenderMarker
-class ItemContext(val parent: RenderParent, val renderFunc: ItemContext.() -> Unit): RenderObject, ObserverListener() {
+class ItemContext(val parent: RenderParent, val renderFunc: ItemContext.() -> Unit): RenderObject, StateFlowListener() {
override fun update() {
val oldX = x
val oldY = y
@@ -40,6 +40,7 @@ class ItemContext(val parent: RenderParent, val renderFunc: ItemContext.() -> Un
}
override fun destroy() {
+ super.destroy()
parent.resetSlot(x, y)
}
diff --git a/KotlinCore/src/de/steamwar/kotlin/ui/context/GroupContext.kt b/KotlinCore/src/de/steamwar/kotlin/ui/context/GroupContext.kt
index f94d70e0..c124ffff 100644
--- a/KotlinCore/src/de/steamwar/kotlin/ui/context/GroupContext.kt
+++ b/KotlinCore/src/de/steamwar/kotlin/ui/context/GroupContext.kt
@@ -19,14 +19,14 @@
package de.steamwar.kotlin.ui.context
-import de.steamwar.kotlin.ui.ObserverListener
+import de.steamwar.kotlin.ui.StateFlowListener
import de.steamwar.kotlin.ui.RenderMarker
import de.steamwar.kotlin.ui.RenderObject
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.inventory.ItemStack
@RenderMarker
-open class GroupContext(val parent: RenderParent?, val init: GroupContext.() -> Unit): ObserverListener(), RenderObject, RenderParent {
+open class GroupContext(val parent: RenderParent?, val init: GroupContext.() -> Unit): StateFlowListener(), RenderObject, RenderParent {
val children = mutableListOf()
val updatedSlots = mutableSetOf>()
@@ -62,4 +62,4 @@ open class GroupContext(val parent: RenderParent?, val init: GroupContext.() ->
init(this)
(oldUpdatedSlots - updatedSlots).forEach { resetSlot(it.first, it.second) }
}
-}
\ No newline at end of file
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 14419caa..bbb758e9 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -128,6 +128,7 @@ dependencyResolutionManagement {
library("msgpack", "org.msgpack:msgpack-core:0.9.8")
library("logback", "ch.qos.logback:logback-classic:1.5.6")
+ library("coroutinesCore", "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
val ktorVersion = "2.3.12"