Add lyng.observable ObservableList hooks, flow events, and docs

This commit is contained in:
Sergey Chernov 2026-03-12 22:07:33 +03:00
parent a9da21e7a4
commit 298c2a09fe
11 changed files with 964 additions and 6 deletions

View File

@ -11,6 +11,7 @@ Concrete collection classes:
- Mutable: [List], [Set], [Map] - Mutable: [List], [Set], [Map]
- Immutable: [ImmutableList], [ImmutableSet], [ImmutableMap] - Immutable: [ImmutableList], [ImmutableSet], [ImmutableMap]
- Observable mutable lists (opt-in module): [ObservableList]
| name | description | | name | description |
|------------------------|------------------------------------------------------| |------------------------|------------------------------------------------------|
@ -27,3 +28,4 @@ See [List], [Set], [Iterable] and [Efficient Iterables in Kotlin Interop](Effici
[ImmutableList]: ImmutableList.md [ImmutableList]: ImmutableList.md
[ImmutableSet]: ImmutableSet.md [ImmutableSet]: ImmutableSet.md
[ImmutableMap]: ImmutableMap.md [ImmutableMap]: ImmutableMap.md
[ObservableList]: ObservableList.md

View File

@ -2,6 +2,7 @@
Mutable list of any objects. Mutable list of any objects.
For immutable list values, see [ImmutableList]. For immutable list values, see [ImmutableList].
For observable mutable lists and change hooks, see [ObservableList].
It's class in Lyng is `List`: 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. 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<Int>)
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<ListChange<T>>` 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<Int>)
assertEquals([30], (e as ListInsert<Int>).values)
it.cancelIteration()
>>> void
## Member inherited from Array ## Member inherited from Array
| name | meaning | type | | name | meaning | type |
@ -199,3 +244,4 @@ It inherits from [Iterable] too and thus all iterable methods are applicable to
[Iterable]: Iterable.md [Iterable]: Iterable.md
[ImmutableList]: ImmutableList.md [ImmutableList]: ImmutableList.md
[ObservableList]: ObservableList.md

70
docs/ObservableList.md Normal file
View File

@ -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<Int>)
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<ListChange<T>>` 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<Int>)
assertEquals(20, (ev as ListSet<Int>).oldValue)
assertEquals(200, ev.newValue)
it.cancelIteration()
>>> void

View File

@ -4704,6 +4704,22 @@ class Compiler(
name == "toImmutableMap" && receiver is TypeDecl.Generic && base == "Iterable" -> { name == "toImmutableMap" && receiver is TypeDecl.Generic && base == "Iterable" -> {
TypeDecl.Generic("ImmutableMap", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false) 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" -> { name == "toMap" && receiver is TypeDecl.Generic && base == "Iterable" -> {
TypeDecl.Generic("Map", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false) TypeDecl.Generic("Map", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false)
} }
@ -4794,6 +4810,9 @@ class Compiler(
"toImmutableList" -> ObjImmutableList.type "toImmutableList" -> ObjImmutableList.type
"toImmutableSet" -> ObjImmutableSet.type "toImmutableSet" -> ObjImmutableSet.type
"toImmutableMap" -> ObjImmutableMap.type "toImmutableMap" -> ObjImmutableMap.type
"observable" -> ObjObservableList.type
"beforeChange", "onChange" -> ObjSubscription.type
"changes" -> ObjFlow.type
"toImmutable" -> Obj.rootObjectType "toImmutable" -> Obj.rootObjectType
"toMutable" -> ObjMutableBuffer.type "toMutable" -> ObjMutableBuffer.type
"seq" -> ObjFlow.type "seq" -> ObjFlow.type
@ -8322,6 +8341,16 @@ class Compiler(
"Regex" -> ObjRegex.type "Regex" -> ObjRegex.type
"RegexMatch" -> ObjRegexMatch.type "RegexMatch" -> ObjRegexMatch.type
"MapEntry" -> ObjMapEntry.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 "Exception" -> ObjException.Root
"Instant" -> ObjInstant.type "Instant" -> ObjInstant.type
"DateTime" -> ObjDateTime.type "DateTime" -> ObjDateTime.type

View File

@ -25,6 +25,7 @@ import net.sergeych.lyng.bytecode.CmdVm
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.* import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.stdlib_included.observableLyng
import net.sergeych.lyng.stdlib_included.rootLyng import net.sergeych.lyng.stdlib_included.rootLyng
import net.sergeych.lynon.ObjLynonClass import net.sergeych.lynon.ObjLynonClass
import net.sergeych.mp_tools.globalDefer import net.sergeych.mp_tools.globalDefer
@ -593,6 +594,19 @@ class Script(
module.eval(Source("lyng.stdlib", rootLyng)) module.eval(Source("lyng.stdlib", rootLyng))
ObjKotlinIterator.bindTo(module.requireClass("KotlinIterator")) 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") { addPackage("lyng.buffer") {
it.addConstDoc( it.addConstDoc(
name = "Buffer", name = "Buffer",

View File

@ -4440,6 +4440,7 @@ class BytecodeCompiler(
val initClass = when (localTarget?.name) { val initClass = when (localTarget?.name) {
"List" -> ObjList.type "List" -> ObjList.type
"ImmutableList" -> ObjImmutableList.type "ImmutableList" -> ObjImmutableList.type
"ObservableList" -> ObjObservableList.type
"Map" -> ObjMap.type "Map" -> ObjMap.type
"ImmutableMap" -> ObjImmutableMap.type "ImmutableMap" -> ObjImmutableMap.type
"Set" -> ObjSet.type "Set" -> ObjSet.type
@ -7326,6 +7327,16 @@ class BytecodeCompiler(
"Regex" -> ObjRegex.type "Regex" -> ObjRegex.type
"RegexMatch" -> ObjRegexMatch.type "RegexMatch" -> ObjRegexMatch.type
"MapEntry" -> ObjMapEntry.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 "Instant" -> ObjInstant.type
"DateTime" -> ObjDateTime.type "DateTime" -> ObjDateTime.type
"Duration" -> ObjDuration.type "Duration" -> ObjDuration.type
@ -7389,6 +7400,9 @@ class BytecodeCompiler(
"toImmutableSet" -> ObjImmutableSet.type "toImmutableSet" -> ObjImmutableSet.type
"toMap" -> ObjMap.type "toMap" -> ObjMap.type
"toImmutableMap" -> ObjImmutableMap.type "toImmutableMap" -> ObjImmutableMap.type
"observable" -> ObjObservableList.type
"beforeChange", "onChange" -> ObjSubscription.type
"changes" -> ObjFlow.type
"joinToString" -> ObjString.type "joinToString" -> ObjString.type
"now", "now",
"truncateToSecond", "truncateToSecond",

View File

@ -47,6 +47,10 @@ object StdlibDocsBootstrap {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val _immutableSet = net.sergeych.lyng.obj.ObjImmutableSet.type val _immutableSet = net.sergeych.lyng.obj.ObjImmutableSet.type
@Suppress("UNUSED_VARIABLE") @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 val _int = net.sergeych.lyng.obj.ObjInt.type
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val _real = net.sergeych.lyng.obj.ObjReal.type val _real = net.sergeych.lyng.obj.ObjReal.type

View File

@ -29,8 +29,8 @@ import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonType import net.sergeych.lynon.LynonType
class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() { open class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
private fun shouldTreatAsSingleElement(scope: Scope, other: Obj): Boolean { protected open fun shouldTreatAsSingleElement(scope: Scope, other: Obj): Boolean {
if (!other.isInstanceOf(ObjIterable)) return true if (!other.isInstanceOf(ObjIterable)) return true
val declaredElementType = scope.declaredListElementTypeForValue(this) val declaredElementType = scope.declaredListElementTypeForValue(this)
if (declaredElementType != null && matchesTypeDecl(scope, other, declaredElementType)) { if (declaredElementType != null && matchesTypeDecl(scope, other, declaredElementType)) {
@ -95,7 +95,7 @@ class ObjList(val list: MutableList<Obj> = 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 list[index.toInt()] = newValue
} }
@ -149,7 +149,7 @@ class ObjList(val list: MutableList<Obj> = 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) { if (other is ObjList) {
list.addAll(other.list) list.addAll(other.list)
} else if (!shouldTreatAsSingleElement(scope, other) && other.isInstanceOf(ObjIterable)) { } else if (!shouldTreatAsSingleElement(scope, other) && other.isInstanceOf(ObjIterable)) {
@ -180,7 +180,7 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
return ObjList(out) 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)) { if (shouldTreatAsSingleElement(scope, other)) {
list.remove(other) list.remove(other)
return this return this
@ -221,7 +221,7 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
} }
} }
override val objClass: ObjClass open override val objClass: ObjClass
get() = type get() = type
override suspend fun toKotlin(scope: Scope): Any { override suspend fun toKotlin(scope: Scope): Any {

View File

@ -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<ObjListSetChange>().index.toObj() })
addPropertyDoc("oldValue", "Value before assignment.", type("lyng.Any"), moduleName = "lyng.observable", getter = { thisAs<ObjListSetChange>().oldValue })
addPropertyDoc("newValue", "Assigned value.", type("lyng.Any"), moduleName = "lyng.observable", getter = { thisAs<ObjListSetChange>().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<ObjListInsertChange>().index.toObj() })
addPropertyDoc(
"values",
"Inserted values.",
TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Any"))),
moduleName = "lyng.observable",
getter = { thisAs<ObjListInsertChange>().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<ObjListRemoveChange>().index.toObj() })
addPropertyDoc("oldValue", "Removed value.", type("lyng.Any"), moduleName = "lyng.observable", getter = { thisAs<ObjListRemoveChange>().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<ObjListClearChange>().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<ObjListReorderChange>().oldValues }
)
addPropertyDoc(
"newValues",
"Values after reorder (or Unset in beforeChange when unknown before commit).",
type("lyng.Any"),
moduleName = "lyng.observable",
getter = { thisAs<ObjListReorderChange>().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<ObjSubscription>().cancel()
ObjVoid
}
}
}
}
private class ObjObservableListFlowProducer(
private val owner: ObjObservableList,
private val subscriptionId: Long,
private val channel: ReceiveChannel<Obj>
) : 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<Obj> = mutableListOf()) : ObjList(list) {
private val access = Mutex()
private var nextSubscriptionId: Long = 1L
private val beforeListeners = linkedMapOf<Long, Obj>()
private val afterListeners = linkedMapOf<Long, Obj>()
private val changeChannels = linkedMapOf<Long, Channel<Obj>>()
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<Obj>, change: Obj) {
val facade = scope.asFacade()
for (listener in listeners) {
facade.call(listener, Arguments(change))
}
}
private suspend fun snapshotBeforeListeners(): List<Obj> = access.withLock { beforeListeners.values.toList() }
private suspend fun snapshotAfterListeners(): List<Obj> = 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<Long>()
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<Obj>(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<Obj>()
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<ObjObservableList>().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<ObjObservableList>().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<ObjObservableList>().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<ObjObservableList>()
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<ObjObservableList>()
val index = requiredArg<ObjInt>(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<ObjObservableList>()
val start = requiredArg<ObjInt>(0).value.toInt()
if (args.size == 2) {
val end = requireOnlyArg<ObjInt>().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<ObjObservableList>()
if (self.list.isEmpty()) return@addFnDoc self
if (args.isNotEmpty()) {
val count = requireOnlyArg<ObjInt>().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<ObjObservableList>()
val before = self.snapshotList()
val copy = self.list.toMutableList()
val range = requiredArg<Obj>(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<ObjInt>(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<ObjObservableList>()
if (self.list.size < 2) return@addFnDoc ObjVoid
val comparator = requireOnlyArg<Obj>()
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<ObjObservableList>()
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
}
}
}
}

View File

@ -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<Change> {
fun beforeChange(listener: (Change)->Void): Subscription
fun onChange(listener: (Change)->Void): Subscription
fun changes(): Flow<Change>
}
extern class ListChange<T>
extern class ListSet<T> : ListChange<T> {
val index: Int
val oldValue: Object
val newValue: Object
}
extern class ListInsert<T> : ListChange<T> {
val index: Int
val values: List<T>
}
extern class ListRemove<T> : ListChange<T> {
val index: Int
val oldValue: Object
}
extern class ListClear<T> : ListChange<T> {
val oldValues: List<T>
}
extern class ListReorder<T> : ListChange<T> {
val oldValues: List<T>
val newValues: Object
}
extern class ObservableList<T> : List<T> {
fun beforeChange(listener: (ListChange<T>)->Void): Subscription
fun onChange(listener: (ListChange<T>)->Void): Subscription
fun changes(): Flow<ListChange<T>>
}
fun List<T>.observable(): ObservableList<T> {
if( this is ObservableList<T> ) this
else ObservableList(...this)
}
""".trimIndent()

View File

@ -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<Int>)
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<Int>)
assertEquals(2, (e1 as ListInsert<Int>).index)
assertEquals([30], e1.values)
xs[1] = 200
assert(it.hasNext())
val e2 = it.next()
assert(e2 is ListSet<Int>)
assertEquals(1, (e2 as ListSet<Int>).index)
assertEquals(20, e2.oldValue)
assertEquals(200, e2.newValue)
xs.removeAt(0)
assert(it.hasNext())
val e3 = it.next()
assert(e3 is ListRemove<Int>)
assertEquals(0, (e3 as ListRemove<Int>).index)
assertEquals(10, e3.oldValue)
it.cancelIteration()
"""
)
}
}