From 3a247de43d075a2a3b9e29032f54944127fb7924 Mon Sep 17 00:00:00 2001 From: Chaoscaot Date: Mon, 6 Jul 2026 01:13:05 +0200 Subject: [PATCH] Replace Custom Observer with kotlinx StateFlows Signed-off-by: Chaoscaot --- KotlinCore/build.gradle.kts | 3 +- .../src/de/steamwar/kotlin/ui/Observer.kt | 101 ------------------ .../src/de/steamwar/kotlin/ui/StateFlow.kt | 90 ++++++++++++++++ ...serverListener.kt => StateFlowListener.kt} | 20 +++- .../src/de/steamwar/kotlin/ui/TestInv.kt | 46 ++++++++ .../src/de/steamwar/kotlin/ui/UIInventory.kt | 4 +- .../kotlin/ui/components/ItemContext.kt | 5 +- .../kotlin/ui/context/GroupContext.kt | 6 +- settings.gradle.kts | 1 + 9 files changed, 162 insertions(+), 114 deletions(-) delete mode 100644 KotlinCore/src/de/steamwar/kotlin/ui/Observer.kt create mode 100644 KotlinCore/src/de/steamwar/kotlin/ui/StateFlow.kt rename KotlinCore/src/de/steamwar/kotlin/ui/{ObserverListener.kt => StateFlowListener.kt} (69%) create mode 100644 KotlinCore/src/de/steamwar/kotlin/ui/TestInv.kt 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"