diff --git a/docs/Collection.md b/docs/Collection.md index 442c457..ca66c71 100644 --- a/docs/Collection.md +++ b/docs/Collection.md @@ -11,6 +11,7 @@ Concrete collection classes: - Mutable: [List], [Set], [Map] - Immutable: [ImmutableList], [ImmutableSet], [ImmutableMap] +- Observable mutable lists (opt-in module): [ObservableList] | name | description | |------------------------|------------------------------------------------------| @@ -27,3 +28,4 @@ See [List], [Set], [Iterable] and [Efficient Iterables in Kotlin Interop](Effici [ImmutableList]: ImmutableList.md [ImmutableSet]: ImmutableSet.md [ImmutableMap]: ImmutableMap.md +[ObservableList]: ObservableList.md diff --git a/docs/List.md b/docs/List.md index 1a0a1cc..d109fe6 100644 --- a/docs/List.md +++ b/docs/List.md @@ -2,6 +2,7 @@ Mutable list of any objects. For immutable list values, see [ImmutableList]. +For observable mutable lists and change hooks, see [ObservableList]. It's class in Lyng is `List`: @@ -180,6 +181,50 @@ for `sort()` will be `sort { a, b -> a <=> b } It inherits from [Iterable] too and thus all iterable methods are applicable to any list. +## Observable list hooks + +Observable hooks are provided by module `lyng.observable` and are opt-in: + + import lyng.observable + + val src = [1,2,3] + val xs = src.observable() + assert(xs is ObservableList) + + var before = 0 + var after = 0 + xs.beforeChange { before++ } + xs.onChange { after++ } + + xs += 4 + xs[0] = 100 + assertEquals([100,2,3,4], xs) + assertEquals(2, before) + assertEquals(2, after) + >>> void + +`beforeChange` runs before mutation commit and may reject it by throwing exception (typically `ChangeRejectionException` from the same module): + + import lyng.observable + val xs = [1,2].observable() + xs.beforeChange { throw ChangeRejectionException("read only") } + assertThrows(ChangeRejectionException) { xs += 3 } + assertEquals([1,2], xs) + >>> void + +`changes()` returns `Flow>` of committed events: + + import lyng.observable + val xs = [10,20].observable() + val it = xs.changes().iterator() + xs += 30 + assert(it.hasNext()) + val e = it.next() + assert(e is ListInsert) + assertEquals([30], (e as ListInsert).values) + it.cancelIteration() + >>> void + ## Member inherited from Array | name | meaning | type | @@ -199,3 +244,4 @@ It inherits from [Iterable] too and thus all iterable methods are applicable to [Iterable]: Iterable.md [ImmutableList]: ImmutableList.md +[ObservableList]: ObservableList.md diff --git a/docs/ObservableList.md b/docs/ObservableList.md new file mode 100644 index 0000000..3654fa1 --- /dev/null +++ b/docs/ObservableList.md @@ -0,0 +1,70 @@ +# ObservableList module + +`ObservableList` lives in explicit module `lyng.observable`. + +Import it first: + + import lyng.observable + >>> void + +Create from a regular mutable list: + + import lyng.observable + val xs = [1,2,3].observable() + assert(xs is ObservableList) + assertEquals([1,2,3], xs) + >>> void + +## Hook flow + +Event order is: +1. `beforeChange(change)` listeners +2. mutation commit +3. `onChange(change)` listeners +4. `changes()` flow emission + +Rejection is done by throwing in `beforeChange`. + + import lyng.observable + val xs = [1,2].observable() + xs.beforeChange { + throw ChangeRejectionException("no mutation") + } + assertThrows(ChangeRejectionException) { xs += 3 } + assertEquals([1,2], xs) + >>> void + +## Subscriptions + +`beforeChange` and `onChange` return `Subscription`. +Call `cancel()` to unsubscribe. + + import lyng.observable + val xs = [1].observable() + var hits = 0 + val sub = xs.onChange { hits++ } + xs += 2 + sub.cancel() + xs += 3 + assertEquals(1, hits) + >>> void + +## Change events + +`changes()` returns `Flow>` with concrete event classes: +- `ListInsert` +- `ListSet` +- `ListRemove` +- `ListClear` +- `ListReorder` + + import lyng.observable + val xs = [10,20].observable() + val it = xs.changes().iterator() + xs[1] = 200 + val ev = it.next() + assert(ev is ListSet) + assertEquals(20, (ev as ListSet).oldValue) + assertEquals(200, ev.newValue) + it.cancelIteration() + >>> void diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 8592995..2fd2b63 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -4704,6 +4704,22 @@ class Compiler( name == "toImmutableMap" && receiver is TypeDecl.Generic && base == "Iterable" -> { TypeDecl.Generic("ImmutableMap", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false) } + name == "observable" && receiver is TypeDecl.Generic && (base == "List" || base == "ObservableList") -> { + val arg = receiver.args.firstOrNull() ?: TypeDecl.TypeAny + TypeDecl.Generic("ObservableList", listOf(arg), false) + } + name == "changes" && receiver is TypeDecl.Generic && base == "ObservableList" -> { + val arg = receiver.args.firstOrNull() ?: TypeDecl.TypeAny + TypeDecl.Generic("Flow", listOf(TypeDecl.Generic("ListChange", listOf(arg), false)), false) + } + name == "changes" && receiver is TypeDecl.Generic && base == "Observable" -> { + val arg = receiver.args.firstOrNull() ?: TypeDecl.TypeAny + TypeDecl.Generic("Flow", listOf(arg), false) + } + (name == "beforeChange" || name == "onChange") && receiver is TypeDecl.Generic && + (base == "Observable" || base == "ObservableList") -> { + TypeDecl.Simple("Subscription", false) + } name == "toMap" && receiver is TypeDecl.Generic && base == "Iterable" -> { TypeDecl.Generic("Map", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false) } @@ -4794,6 +4810,9 @@ class Compiler( "toImmutableList" -> ObjImmutableList.type "toImmutableSet" -> ObjImmutableSet.type "toImmutableMap" -> ObjImmutableMap.type + "observable" -> ObjObservableList.type + "beforeChange", "onChange" -> ObjSubscription.type + "changes" -> ObjFlow.type "toImmutable" -> Obj.rootObjectType "toMutable" -> ObjMutableBuffer.type "seq" -> ObjFlow.type @@ -8322,6 +8341,16 @@ class Compiler( "Regex" -> ObjRegex.type "RegexMatch" -> ObjRegexMatch.type "MapEntry" -> ObjMapEntry.type + "Observable" -> ObjObservable + "Subscription" -> ObjSubscription.type + "ListChange" -> ObjListChange.type + "ListSet" -> ObjListSetChange.type + "ListInsert" -> ObjListInsertChange.type + "ListRemove" -> ObjListRemoveChange.type + "ListClear" -> ObjListClearChange.type + "ListReorder" -> ObjListReorderChange.type + "ObservableList" -> ObjObservableList.type + "ChangeRejectionException" -> ObjChangeRejectionExceptionClass "Exception" -> ObjException.Root "Instant" -> ObjInstant.type "DateTime" -> ObjDateTime.type diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 8ba2ffb..0623115 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -25,6 +25,7 @@ import net.sergeych.lyng.bytecode.CmdVm import net.sergeych.lyng.miniast.* import net.sergeych.lyng.obj.* import net.sergeych.lyng.pacman.ImportManager +import net.sergeych.lyng.stdlib_included.observableLyng import net.sergeych.lyng.stdlib_included.rootLyng import net.sergeych.lynon.ObjLynonClass import net.sergeych.mp_tools.globalDefer @@ -593,6 +594,19 @@ class Script( module.eval(Source("lyng.stdlib", rootLyng)) ObjKotlinIterator.bindTo(module.requireClass("KotlinIterator")) } + addPackage("lyng.observable") { module -> + module.addConst("Observable", ObjObservable) + module.addConst("Subscription", ObjSubscription.type) + module.addConst("ListChange", ObjListChange.type) + module.addConst("ListSet", ObjListSetChange.type) + module.addConst("ListInsert", ObjListInsertChange.type) + module.addConst("ListRemove", ObjListRemoveChange.type) + module.addConst("ListClear", ObjListClearChange.type) + module.addConst("ListReorder", ObjListReorderChange.type) + module.addConst("ObservableList", ObjObservableList.type) + module.addConst("ChangeRejectionException", ObjChangeRejectionExceptionClass) + module.eval(Source("lyng.observable", observableLyng)) + } addPackage("lyng.buffer") { it.addConstDoc( name = "Buffer", diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index e9cecc4..f18186b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -4440,6 +4440,7 @@ class BytecodeCompiler( val initClass = when (localTarget?.name) { "List" -> ObjList.type "ImmutableList" -> ObjImmutableList.type + "ObservableList" -> ObjObservableList.type "Map" -> ObjMap.type "ImmutableMap" -> ObjImmutableMap.type "Set" -> ObjSet.type @@ -7326,6 +7327,16 @@ class BytecodeCompiler( "Regex" -> ObjRegex.type "RegexMatch" -> ObjRegexMatch.type "MapEntry" -> ObjMapEntry.type + "Observable" -> ObjObservable + "Subscription" -> ObjSubscription.type + "ListChange" -> ObjListChange.type + "ListSet" -> ObjListSetChange.type + "ListInsert" -> ObjListInsertChange.type + "ListRemove" -> ObjListRemoveChange.type + "ListClear" -> ObjListClearChange.type + "ListReorder" -> ObjListReorderChange.type + "ObservableList" -> ObjObservableList.type + "ChangeRejectionException" -> ObjChangeRejectionExceptionClass "Instant" -> ObjInstant.type "DateTime" -> ObjDateTime.type "Duration" -> ObjDuration.type @@ -7389,6 +7400,9 @@ class BytecodeCompiler( "toImmutableSet" -> ObjImmutableSet.type "toMap" -> ObjMap.type "toImmutableMap" -> ObjImmutableMap.type + "observable" -> ObjObservableList.type + "beforeChange", "onChange" -> ObjSubscription.type + "changes" -> ObjFlow.type "joinToString" -> ObjString.type "now", "truncateToSecond", diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt index 035b9cf..313299b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt @@ -47,6 +47,10 @@ object StdlibDocsBootstrap { @Suppress("UNUSED_VARIABLE") val _immutableSet = net.sergeych.lyng.obj.ObjImmutableSet.type @Suppress("UNUSED_VARIABLE") + val _observableList = net.sergeych.lyng.obj.ObjObservableList.type + @Suppress("UNUSED_VARIABLE") + val _subscription = net.sergeych.lyng.obj.ObjSubscription.type + @Suppress("UNUSED_VARIABLE") val _int = net.sergeych.lyng.obj.ObjInt.type @Suppress("UNUSED_VARIABLE") val _real = net.sergeych.lyng.obj.ObjReal.type diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjList.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjList.kt index 0f43e0b..8243f8a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjList.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjList.kt @@ -29,8 +29,8 @@ import net.sergeych.lynon.LynonDecoder import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonType -class ObjList(val list: MutableList = mutableListOf()) : Obj() { - private fun shouldTreatAsSingleElement(scope: Scope, other: Obj): Boolean { +open class ObjList(val list: MutableList = mutableListOf()) : Obj() { + protected open fun shouldTreatAsSingleElement(scope: Scope, other: Obj): Boolean { if (!other.isInstanceOf(ObjIterable)) return true val declaredElementType = scope.declaredListElementTypeForValue(this) if (declaredElementType != null && matchesTypeDecl(scope, other, declaredElementType)) { @@ -95,7 +95,7 @@ class ObjList(val list: MutableList = mutableListOf()) : Obj() { } } - override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) { + open override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) { list[index.toInt()] = newValue } @@ -149,7 +149,7 @@ class ObjList(val list: MutableList = mutableListOf()) : Obj() { } - override suspend fun plusAssign(scope: Scope, other: Obj): Obj { + open override suspend fun plusAssign(scope: Scope, other: Obj): Obj { if (other is ObjList) { list.addAll(other.list) } else if (!shouldTreatAsSingleElement(scope, other) && other.isInstanceOf(ObjIterable)) { @@ -180,7 +180,7 @@ class ObjList(val list: MutableList = mutableListOf()) : Obj() { return ObjList(out) } - override suspend fun minusAssign(scope: Scope, other: Obj): Obj { + open override suspend fun minusAssign(scope: Scope, other: Obj): Obj { if (shouldTreatAsSingleElement(scope, other)) { list.remove(other) return this @@ -221,7 +221,7 @@ class ObjList(val list: MutableList = mutableListOf()) : Obj() { } } - override val objClass: ObjClass + open override val objClass: ObjClass get() = type override suspend fun toKotlin(scope: Scope): Any { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjObservableList.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjObservableList.kt new file mode 100644 index 0000000..56c7af6 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjObservableList.kt @@ -0,0 +1,589 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.obj + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.sergeych.lyng.Arguments +import net.sergeych.lyng.Scope +import net.sergeych.lyng.asFacade +import net.sergeych.lyng.miniast.ParamDoc +import net.sergeych.lyng.miniast.TypeGenericDoc +import net.sergeych.lyng.miniast.addFnDoc +import net.sergeych.lyng.miniast.addPropertyDoc +import net.sergeych.lyng.miniast.type + +val ObjObservable = ObjClass("Observable").apply { + addFn("beforeChange") { raiseNotImplemented("Observable.beforeChange") } + addFn("onChange") { raiseNotImplemented("Observable.onChange") } + addFn("changes") { raiseNotImplemented("Observable.changes") } +} + +val ObjChangeRejectionExceptionClass = ObjException.Companion.ExceptionClass("ChangeRejectionException", ObjException.Root) + +open class ObjListChange : Obj() { + override val objClass: ObjClass get() = type + + companion object { + val type = ObjClass("ListChange") + } +} + +class ObjListSetChange(val index: Int, val oldValue: Obj, val newValue: Obj) : ObjListChange() { + override val objClass: ObjClass get() = type + + companion object { + val type = ObjClass("ListSet", ObjListChange.type).apply { + addPropertyDoc("index", "Changed index.", type("lyng.Int"), moduleName = "lyng.observable", getter = { thisAs().index.toObj() }) + addPropertyDoc("oldValue", "Value before assignment.", type("lyng.Any"), moduleName = "lyng.observable", getter = { thisAs().oldValue }) + addPropertyDoc("newValue", "Assigned value.", type("lyng.Any"), moduleName = "lyng.observable", getter = { thisAs().newValue }) + } + } +} + +class ObjListInsertChange(val index: Int, val values: ObjList) : ObjListChange() { + override val objClass: ObjClass get() = type + + companion object { + val type = ObjClass("ListInsert", ObjListChange.type).apply { + addPropertyDoc("index", "Insertion index.", type("lyng.Int"), moduleName = "lyng.observable", getter = { thisAs().index.toObj() }) + addPropertyDoc( + "values", + "Inserted values.", + TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Any"))), + moduleName = "lyng.observable", + getter = { thisAs().values } + ) + } + } +} + +class ObjListRemoveChange(val index: Int, val oldValue: Obj) : ObjListChange() { + override val objClass: ObjClass get() = type + + companion object { + val type = ObjClass("ListRemove", ObjListChange.type).apply { + addPropertyDoc("index", "Removed index.", type("lyng.Int"), moduleName = "lyng.observable", getter = { thisAs().index.toObj() }) + addPropertyDoc("oldValue", "Removed value.", type("lyng.Any"), moduleName = "lyng.observable", getter = { thisAs().oldValue }) + } + } +} + +class ObjListClearChange(val oldValues: ObjList) : ObjListChange() { + override val objClass: ObjClass get() = type + + companion object { + val type = ObjClass("ListClear", ObjListChange.type).apply { + addPropertyDoc( + "oldValues", + "All list values before clear.", + TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Any"))), + moduleName = "lyng.observable", + getter = { thisAs().oldValues } + ) + } + } +} + +class ObjListReorderChange(val oldValues: ObjList, val newValues: Obj) : ObjListChange() { + override val objClass: ObjClass get() = type + + companion object { + val type = ObjClass("ListReorder", ObjListChange.type).apply { + addPropertyDoc( + "oldValues", + "Values before reorder.", + TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Any"))), + moduleName = "lyng.observable", + getter = { thisAs().oldValues } + ) + addPropertyDoc( + "newValues", + "Values after reorder (or Unset in beforeChange when unknown before commit).", + type("lyng.Any"), + moduleName = "lyng.observable", + getter = { thisAs().newValues } + ) + } + } +} + +class ObjSubscription(private val onCancel: suspend () -> Unit) : Obj() { + private val access = Mutex() + private var cancelled = false + + override val objClass: ObjClass get() = type + + suspend fun cancel() { + var action: (suspend () -> Unit)? = null + access.withLock { + if (!cancelled) { + cancelled = true + action = onCancel + } + } + action?.invoke() + } + + companion object { + val type = object : ObjClass("Subscription") { + override suspend fun callOn(scope: Scope): Obj = scope.raiseIllegalArgument("Subscription constructor is not available") + }.apply { + addFnDoc( + "cancel", + "Unsubscribe listener. Safe to call multiple times.", + returns = type("lyng.Void"), + moduleName = "lyng.observable" + ) { + thisAs().cancel() + ObjVoid + } + } + } +} + +private class ObjObservableListFlowProducer( + private val owner: ObjObservableList, + private val subscriptionId: Long, + private val channel: ReceiveChannel +) : Obj() { + override val objClass: ObjClass get() = Obj.rootObjectType + + override suspend fun callOn(scope: Scope): Obj { + val builder = scope.thisObj as? ObjFlowBuilder + ?: scope.raiseIllegalState("flow builder is not available") + try { + for (change in channel) { + builder.invokeInstanceMethod(scope, "emit", change) + } + } finally { + owner.unsubscribeChangeChannel(subscriptionId) + } + return ObjVoid + } +} + +class ObjObservableList(list: MutableList = mutableListOf()) : ObjList(list) { + private val access = Mutex() + private var nextSubscriptionId: Long = 1L + private val beforeListeners = linkedMapOf() + private val afterListeners = linkedMapOf() + private val changeChannels = linkedMapOf>() + + override val objClass: ObjClass get() = type + + private fun nextId(): Long = nextSubscriptionId++ + + private suspend fun addBeforeListener(listener: Obj): ObjSubscription { + val id = access.withLock { + val id = nextId() + beforeListeners[id] = listener + id + } + return ObjSubscription { + access.withLock { beforeListeners.remove(id) } + } + } + + private suspend fun addAfterListener(listener: Obj): ObjSubscription { + val id = access.withLock { + val id = nextId() + afterListeners[id] = listener + id + } + return ObjSubscription { + access.withLock { afterListeners.remove(id) } + } + } + + private suspend fun notifyListeners(scope: Scope, listeners: Collection, change: Obj) { + val facade = scope.asFacade() + for (listener in listeners) { + facade.call(listener, Arguments(change)) + } + } + + private suspend fun snapshotBeforeListeners(): List = access.withLock { beforeListeners.values.toList() } + private suspend fun snapshotAfterListeners(): List = access.withLock { afterListeners.values.toList() } + + private suspend fun notifyBefore(scope: Scope, change: Obj) { + notifyListeners(scope, snapshotBeforeListeners(), change) + } + + private suspend fun notifyAfter(scope: Scope, change: Obj) { + notifyListeners(scope, snapshotAfterListeners(), change) + } + + private suspend fun emitCommittedChange(change: Obj) { + val channels = access.withLock { changeChannels.toMap() } + val dead = mutableListOf() + for ((id, channel) in channels) { + if (channel.trySend(change).isFailure) { + dead += id + } + } + if (dead.isNotEmpty()) { + access.withLock { + for (id in dead) { + changeChannels.remove(id)?.close() + } + } + } + } + + private suspend fun mutate(scope: Scope, beforeChange: Obj, mutation: suspend () -> Obj?): Obj? { + notifyBefore(scope, beforeChange) + val committed = mutation() + if (committed != null) { + notifyAfter(scope, committed) + emitCommittedChange(committed) + } + return committed + } + + private fun snapshotList(): ObjList = ObjList(list.toMutableList()) + + private suspend fun createChangesFlow(scope: Scope): ObjFlow { + val channel = Channel(Channel.UNLIMITED) + val id = access.withLock { + val id = nextId() + changeChannels[id] = channel + id + } + val producer = ObjObservableListFlowProducer(this, id, channel) + return ObjFlow(producer, scope) + } + + internal suspend fun unsubscribeChangeChannel(id: Long) { + access.withLock { + changeChannels.remove(id)?.close() + } + } + + override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) { + val idx = index.toInt() + val old = list[idx] + val change = ObjListSetChange(idx, old, newValue) + mutate(scope, change) { + list[idx] = newValue + change + } + } + + override suspend fun plusAssign(scope: Scope, other: Obj): Obj { + when { + other is ObjList -> { + if (other.list.isEmpty()) return this + val insert = ObjListInsertChange(list.size, ObjList(other.list.toMutableList())) + mutate(scope, insert) { + list.addAll(other.list) + insert + } + } + + !shouldTreatAsSingleElement(scope, other) && other.isInstanceOf(ObjIterable) -> { + val otherList = (other.invokeInstanceMethod(scope, "toList") as ObjList).list + if (otherList.isEmpty()) return this + val insert = ObjListInsertChange(list.size, ObjList(otherList.toMutableList())) + mutate(scope, insert) { + list.addAll(otherList) + insert + } + } + + else -> { + val insert = ObjListInsertChange(list.size, ObjList(mutableListOf(other))) + mutate(scope, insert) { + list.add(other) + insert + } + } + } + return this + } + + override suspend fun minusAssign(scope: Scope, other: Obj): Obj { + if (shouldTreatAsSingleElement(scope, other)) { + val index = list.indexOf(other) + if (index < 0) return this + val old = list[index] + val remove = ObjListRemoveChange(index, old) + mutate(scope, remove) { + list.removeAt(index) + remove + } + return this + } + if (other.isInstanceOf(ObjIterable)) { + val toRemove = mutableSetOf() + other.enumerate(scope) { + toRemove += it + true + } + val oldValues = snapshotList() + val filtered = list.filterNot { toRemove.contains(it) } + if (filtered.size == list.size) return this + val committed = if (filtered.isEmpty()) { + ObjListClearChange(oldValues) + } else { + ObjListReorderChange(oldValues, ObjList(filtered.toMutableList())) + } + mutate(scope, committed) { + list.clear() + list.addAll(filtered) + committed + } + return this + } + val index = list.indexOf(other) + if (index < 0) return this + val old = list[index] + val remove = ObjListRemoveChange(index, old) + mutate(scope, remove) { + list.removeAt(index) + remove + } + return this + } + + companion object { + val type = object : ObjClass("ObservableList", ObjList.type) { + override suspend fun callOn(scope: Scope): Obj { + return ObjObservableList(scope.args.list.toMutableList()) + } + }.apply { + addFnDoc( + "beforeChange", + "Subscribe to pre-commit list changes. Throw to reject mutation.", + params = listOf(ParamDoc("listener", type("lyng.Any"))), + returns = type("lyng.observable.Subscription"), + moduleName = "lyng.observable" + ) { + thisAs().addBeforeListener(requireOnlyArg()) + } + addFnDoc( + "onChange", + "Subscribe to post-commit list changes.", + params = listOf(ParamDoc("listener", type("lyng.Any"))), + returns = type("lyng.observable.Subscription"), + moduleName = "lyng.observable" + ) { + thisAs().addAfterListener(requireOnlyArg()) + } + addFnDoc( + "changes", + "Get a flow of committed list changes.", + returns = TypeGenericDoc(type("lyng.Flow"), listOf(type("lyng.observable.ListChange"))), + moduleName = "lyng.observable" + ) { + thisAs().createChangesFlow(requireScope()) + } + addFnDoc( + "observable", + "Observable list is already observable; returns this.", + returns = type("lyng.observable.ObservableList"), + moduleName = "lyng.observable" + ) { thisObj } + addFnDoc( + "add", + "Append one or more elements and emit ListInsert.", + moduleName = "lyng.observable" + ) { + val self = thisAs() + if (args.isEmpty()) return@addFnDoc ObjVoid + val inserted = ObjList(args.list.toMutableList()) + val change = ObjListInsertChange(self.list.size, inserted) + self.mutate(requireScope(), change) { + self.list.addAll(args.list) + change + } + ObjVoid + } + addFnDoc( + "insertAt", + "Insert one or more values at index and emit ListInsert.", + params = listOf(ParamDoc("index", type("lyng.Int"))), + moduleName = "lyng.observable" + ) { + if (args.size < 2) raiseError("insertAt takes 2+ arguments") + val self = thisAs() + val index = requiredArg(0).value.toInt() + val values = args.list.drop(1) + val change = ObjListInsertChange(index, ObjList(values.toMutableList())) + self.mutate(requireScope(), change) { + var i = index + for (v in values) self.list.add(i++, v) + change + } + ObjVoid + } + addFnDoc( + "removeAt", + "Remove element at index or range and emit remove/reorder/clear changes.", + params = listOf(ParamDoc("start", type("lyng.Int")), ParamDoc("end", type("lyng.Int"))), + moduleName = "lyng.observable" + ) { + val self = thisAs() + val start = requiredArg(0).value.toInt() + if (args.size == 2) { + val end = requireOnlyArg().value.toInt() + val oldValues = self.snapshotList() + val newValues = self.list.toMutableList().apply { subList(start, end).clear() } + if (newValues == self.list) return@addFnDoc self + val change = if (newValues.isEmpty()) ObjListClearChange(oldValues) + else ObjListReorderChange(oldValues, ObjList(newValues.toMutableList())) + self.mutate(requireScope(), change) { + self.list.subList(start, end).clear() + change + } + } else { + val old = self.list[start] + val change = ObjListRemoveChange(start, old) + self.mutate(requireScope(), change) { + self.list.removeAt(start) + change + } + } + self + } + addFnDoc( + "removeLast", + "Remove last element(s) and emit remove/reorder/clear changes.", + params = listOf(ParamDoc("count", type("lyng.Int"))), + moduleName = "lyng.observable" + ) { + val self = thisAs() + if (self.list.isEmpty()) return@addFnDoc self + if (args.isNotEmpty()) { + val count = requireOnlyArg().value.toInt() + if (count <= 0) return@addFnDoc self + val oldValues = self.snapshotList() + val newValues = if (count >= self.list.size) emptyList() else self.list.subList(0, self.list.size - count).toList() + val change = if (newValues.isEmpty()) ObjListClearChange(oldValues) + else ObjListReorderChange(oldValues, ObjList(newValues.toMutableList())) + self.mutate(requireScope(), change) { + if (count >= self.list.size) self.list.clear() + else self.list.subList(self.list.size - count, self.list.size).clear() + change + } + } else { + val index = self.list.lastIndex + val old = self.list[index] + val change = ObjListRemoveChange(index, old) + self.mutate(requireScope(), change) { + self.list.removeAt(index) + change + } + } + self + } + addFnDoc( + "removeRange", + "Remove a range and emit reorder/clear changes.", + params = listOf(ParamDoc("range")), + moduleName = "lyng.observable" + ) { + val self = thisAs() + val before = self.snapshotList() + val copy = self.list.toMutableList() + val range = requiredArg(0) + if (range is ObjRange) { + val index = range + when { + index.start is ObjInt && index.end is ObjInt -> { + if (index.isEndInclusive) copy.subList(index.start.toInt(), index.end.toInt() + 1) + else copy.subList(index.start.toInt(), index.end.toInt()) + } + + index.isOpenStart && !index.isOpenEnd -> { + if (index.isEndInclusive) copy.subList(0, index.end!!.toInt() + 1) + else copy.subList(0, index.end!!.toInt()) + } + + index.isOpenEnd && !index.isOpenStart -> { + copy.subList(index.start!!.toInt(), copy.size) + } + + index.isOpenStart && index.isOpenEnd -> { + copy + } + + else -> { + throw RuntimeException("Can't apply range for index: $index") + } + }.clear() + } else { + val start = range.toInt() + val end = requiredArg(1).value.toInt() + 1 + copy.subList(start, end).clear() + } + if (copy == self.list) return@addFnDoc self + val change = if (copy.isEmpty()) ObjListClearChange(before) + else ObjListReorderChange(before, ObjList(copy.toMutableList())) + self.mutate(requireScope(), change) { + self.list.clear() + self.list.addAll(copy) + change + } + self + } + addFnDoc( + "sortWith", + "Sort list in place and emit ListReorder.", + params = listOf(ParamDoc("comparator")), + moduleName = "lyng.observable" + ) { + val self = thisAs() + if (self.list.size < 2) return@addFnDoc ObjVoid + val comparator = requireOnlyArg() + val oldValues = self.snapshotList() + val beforeChange = ObjListReorderChange(oldValues, ObjUnset) + self.notifyBefore(requireScope(), beforeChange) + self.quicksort { a, b -> call(comparator, Arguments(a, b)).toInt() } + val newValues = self.snapshotList() + if (oldValues.list != newValues.list) { + val committed = ObjListReorderChange(oldValues, newValues) + self.notifyAfter(requireScope(), committed) + self.emitCommittedChange(committed) + } + ObjVoid + } + addFnDoc( + "shuffle", + "Shuffle list in place and emit ListReorder.", + moduleName = "lyng.observable" + ) { + val self = thisAs() + if (self.list.size < 2) return@addFnDoc ObjVoid + val oldValues = self.snapshotList() + val beforeChange = ObjListReorderChange(oldValues, ObjUnset) + self.notifyBefore(requireScope(), beforeChange) + self.list.shuffle() + val newValues = self.snapshotList() + if (oldValues.list != newValues.list) { + val committed = ObjListReorderChange(oldValues, newValues) + self.notifyAfter(requireScope(), committed) + self.emitCommittedChange(committed) + } + ObjVoid + } + } + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/observable_lyng.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/observable_lyng.kt new file mode 100644 index 0000000..03776b7 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/stdlib_included/observable_lyng.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.stdlib_included + +@Suppress("Unused", "MemberVisibilityCanBePrivate") +internal val observableLyng = """ +package lyng.observable + +extern class ChangeRejectionException : Exception + +extern class Subscription { + fun cancel(): Void +} + +extern class Observable { + fun beforeChange(listener: (Change)->Void): Subscription + fun onChange(listener: (Change)->Void): Subscription + fun changes(): Flow +} + +extern class ListChange + +extern class ListSet : ListChange { + val index: Int + val oldValue: Object + val newValue: Object +} + +extern class ListInsert : ListChange { + val index: Int + val values: List +} + +extern class ListRemove : ListChange { + val index: Int + val oldValue: Object +} + +extern class ListClear : ListChange { + val oldValues: List +} + +extern class ListReorder : ListChange { + val oldValues: List + val newValues: Object +} + +extern class ObservableList : List { + fun beforeChange(listener: (ListChange)->Void): Subscription + fun onChange(listener: (ListChange)->Void): Subscription + fun changes(): Flow> +} + +fun List.observable(): ObservableList { + if( this is ObservableList ) this + else ObservableList(...this) +} +""".trimIndent() diff --git a/lynglib/src/commonTest/kotlin/ObservableListTest.kt b/lynglib/src/commonTest/kotlin/ObservableListTest.kt new file mode 100644 index 0000000..5ead4af --- /dev/null +++ b/lynglib/src/commonTest/kotlin/ObservableListTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test + +class ObservableListTest { + @Test + fun observableHooksAndSubscriptionCancel() = runTest { + eval( + """ + import lyng.observable + + val src = [1,2] + val xs = src.observable() + assert(xs is ObservableList) + assert(src == [1,2]) + assert(xs == [1,2]) + + var before = 0 + var after = 0 + val beforeSub = xs.beforeChange { before++ } + val afterSub = xs.onChange { after++ } + + xs += 3 + assertEquals([1,2,3], xs) + assertEquals(1, before) + assertEquals(1, after) + + afterSub.cancel() + xs[0] = 100 + assertEquals([100,2,3], xs) + assertEquals(2, before) + assertEquals(1, after) + + beforeSub.cancel() + xs.removeAt(0) + assertEquals([2,3], xs) + assertEquals(2, before) + assertEquals(1, after) + """ + ) + } + + @Test + fun observableBeforeChangeRejectsMutation() = runTest { + eval( + """ + import lyng.observable + + val xs = [1,2].observable() + var after = 0 + xs.beforeChange { + throw ChangeRejectionException("no changes accepted") + } + xs.onChange { after++ } + + assertThrows(ChangeRejectionException) { + xs += 3 + } + assertEquals([1,2], xs) + assertEquals(0, after) + """ + ) + } + + @Test + fun observableChangesFlowEmitsCommittedEvents() = runTest { + eval( + """ + import lyng.observable + + val xs = [10,20].observable() + val it = xs.changes().iterator() + + xs += 30 + assert(it.hasNext()) + val e1 = it.next() + assert(e1 is ListInsert) + assertEquals(2, (e1 as ListInsert).index) + assertEquals([30], e1.values) + + xs[1] = 200 + assert(it.hasNext()) + val e2 = it.next() + assert(e2 is ListSet) + assertEquals(1, (e2 as ListSet).index) + assertEquals(20, e2.oldValue) + assertEquals(200, e2.newValue) + + xs.removeAt(0) + assert(it.hasNext()) + val e3 = it.next() + assert(e3 is ListRemove) + assertEquals(0, (e3 as ListRemove).index) + assertEquals(10, e3.oldValue) + + it.cancelIteration() + """ + ) + } +}