Improved observable API and slightly simplified implementation of some observables.

This commit is contained in:
Daan Vanden Bosch 2021-12-04 20:55:12 +01:00
parent b84ef99e22
commit 89ea739c65
66 changed files with 453 additions and 413 deletions

View File

@ -0,0 +1,33 @@
package world.phantasmal.observable
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.unsafe.unsafeCast
/**
* Calls [callback] when [dependency] changes.
*/
class CallbackChangeObserver<T, E : ChangeEvent<T>>(
private val dependency: Dependency,
// We don't use Observer<T> because of type system limitations. It would break e.g.
// AbstractListCell.observeListChange.
private val callback: (E) -> Unit,
) : TrackedDisposable(), Dependent {
init {
dependency.addDependent(this)
}
override fun dispose() {
dependency.removeDependent(this)
super.dispose()
}
override fun dependencyMightChange() {
// Do nothing.
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
callback(unsafeCast(event))
}
}
}

View File

@ -1,28 +1,46 @@
package world.phantasmal.observable package world.phantasmal.observable
import world.phantasmal.core.disposable.TrackedDisposable import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.unsafe.unsafeCast
class CallbackObserver<T, E : ChangeEvent<T>>( /**
private val dependency: Dependency, * Calls [callback] when one or more dependency in [dependencies] changes.
private val callback: (E) -> Unit, */
class CallbackObserver(
private vararg val dependencies: Dependency,
private val callback: () -> Unit,
) : TrackedDisposable(), Dependent { ) : TrackedDisposable(), Dependent {
private var changingDependencies = 0
private var dependenciesActuallyChanged = false
init { init {
dependency.addDependent(this) for (dependency in dependencies) {
dependency.addDependent(this)
}
} }
override fun dispose() { override fun dispose() {
dependency.removeDependent(this) for (dependency in dependencies) {
dependency.removeDependent(this)
}
super.dispose() super.dispose()
} }
override fun dependencyMightChange() { override fun dependencyMightChange() {
// Do nothing. changingDependencies++
} }
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) { override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) { if (event != null) {
callback(unsafeCast(event)) dependenciesActuallyChanged = true
}
changingDependencies--
if (changingDependencies == 0 && dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
callback()
} }
} }
} }

View File

@ -1,5 +1,7 @@
package world.phantasmal.observable package world.phantasmal.observable
typealias ChangeObserver<T> = (ChangeEvent<T>) -> Unit
open class ChangeEvent<out T>( open class ChangeEvent<out T>(
/** /**
* The observable's new value. * The observable's new value.
@ -8,5 +10,3 @@ open class ChangeEvent<out T>(
) { ) {
operator fun component1() = value operator fun component1() = value
} }
typealias Observer<T> = (ChangeEvent<T>) -> Unit

View File

@ -3,7 +3,7 @@ package world.phantasmal.observable
interface Dependency { interface Dependency {
/** /**
* This method is not meant to be called from typical application code. Usually you'll want to * This method is not meant to be called from typical application code. Usually you'll want to
* use [Observable.observe]. * use [Observable.observeChange].
*/ */
fun addDependent(dependent: Dependent) fun addDependent(dependent: Dependent)

View File

@ -3,5 +3,8 @@ package world.phantasmal.observable
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
interface Observable<out T> : Dependency { interface Observable<out T> : Dependency {
fun observe(observer: Observer<T>): Disposable /**
* [observer] will be called whenever this observable changes.
*/
fun observeChange(observer: ChangeObserver<T>): Disposable
} }

View File

@ -1,3 +0,0 @@
package world.phantasmal.observable
fun <T> emitter(): Emitter<T> = SimpleEmitter()

View File

@ -0,0 +1,8 @@
package world.phantasmal.observable
import world.phantasmal.core.disposable.Disposable
fun <T> emitter(): Emitter<T> = SimpleEmitter()
fun <T> Observable<T>.observe(observer: (T) -> Unit): Disposable =
observeChange { observer(it.value) }

View File

@ -15,8 +15,8 @@ class SimpleEmitter<T> : AbstractDependency(), Emitter<T> {
ChangeManager.changed(this) ChangeManager.changed(this)
} }
override fun observe(observer: Observer<T>): Disposable = override fun observeChange(observer: ChangeObserver<T>): Disposable =
CallbackObserver(this, observer) CallbackChangeObserver(this, observer)
override fun emitDependencyChanged() { override fun emitDependencyChanged() {
if (event != null) { if (event != null) {

View File

@ -2,25 +2,15 @@ package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.observable.AbstractDependency import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.CallbackObserver import world.phantasmal.observable.CallbackChangeObserver
import world.phantasmal.observable.ChangeEvent import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Observer import world.phantasmal.observable.ChangeObserver
abstract class AbstractCell<T> : AbstractDependency(), Cell<T> { abstract class AbstractCell<T> : AbstractDependency(), Cell<T> {
private var mightChangeEmitted = false private var mightChangeEmitted = false
final override fun observe(observer: Observer<T>): Disposable = override fun observeChange(observer: ChangeObserver<T>): Disposable =
observe(callNow = false, observer) CallbackChangeObserver(this, observer)
override fun observe(callNow: Boolean, observer: Observer<T>): Disposable {
val observingCell = CallbackObserver(this, observer)
if (callNow) {
observer(ChangeEvent(value))
}
return observingCell
}
protected fun emitMightChange() { protected fun emitMightChange() {
if (!mightChangeEmitted) { if (!mightChangeEmitted) {

View File

@ -1,10 +1,6 @@
package world.phantasmal.observable.cell package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.observable.Observable import world.phantasmal.observable.Observable
import world.phantasmal.observable.Observer
import world.phantasmal.observable.cell.list.DependentListCell
import world.phantasmal.observable.cell.list.ListCell
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
/** /**
@ -14,9 +10,4 @@ interface Cell<out T> : Observable<T> {
val value: T val value: T
operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value
/**
* @param callNow Call [observer] immediately with the current [value].
*/
fun observe(callNow: Boolean = false, observer: Observer<T>): Disposable
} }

View File

@ -1,5 +1,8 @@
package world.phantasmal.observable.cell package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.observable.CallbackObserver
private val TRUE_CELL: Cell<Boolean> = ImmutableCell(true) private val TRUE_CELL: Cell<Boolean> = ImmutableCell(true)
private val FALSE_CELL: Cell<Boolean> = ImmutableCell(false) private val FALSE_CELL: Cell<Boolean> = ImmutableCell(false)
private val NULL_CELL: Cell<Nothing?> = ImmutableCell(null) private val NULL_CELL: Cell<Nothing?> = ImmutableCell(null)
@ -36,6 +39,62 @@ fun <T> mutableCell(value: T): MutableCell<T> = SimpleCell(value)
fun <T> mutableCell(getter: () -> T, setter: (T) -> Unit): MutableCell<T> = fun <T> mutableCell(getter: () -> T, setter: (T) -> Unit): MutableCell<T> =
DelegatingCell(getter, setter) DelegatingCell(getter, setter)
fun <T> Cell<T>.observeNow(
observer: (T) -> Unit,
): Disposable {
observer(value)
return observeChange { observer(it.value) }
}
fun <T1, T2> observeNow(
c1: Cell<T1>,
c2: Cell<T2>,
observer: (T1, T2) -> Unit,
): Disposable {
observer(c1.value, c2.value)
return CallbackObserver(c1, c2) { observer(c1.value, c2.value) }
}
fun <T1, T2, T3> observeNow(
c1: Cell<T1>,
c2: Cell<T2>,
c3: Cell<T3>,
observer: (T1, T2, T3) -> Unit,
): Disposable {
observer(c1.value, c2.value, c3.value)
return CallbackObserver(c1, c2, c3) { observer(c1.value, c2.value, c3.value) }
}
fun <T1, T2, T3, T4> observeNow(
c1: Cell<T1>,
c2: Cell<T2>,
c3: Cell<T3>,
c4: Cell<T4>,
observer: (T1, T2, T3, T4) -> Unit,
): Disposable {
observer(c1.value, c2.value, c3.value, c4.value)
return CallbackObserver(c1, c2, c3, c4) { observer(c1.value, c2.value, c3.value, c4.value) }
}
fun <T1, T2, T3, T4, T5> observeNow(
c1: Cell<T1>,
c2: Cell<T2>,
c3: Cell<T3>,
c4: Cell<T4>,
c5: Cell<T5>,
observer: (T1, T2, T3, T4, T5) -> Unit,
): Disposable {
observer(c1.value, c2.value, c3.value, c4.value, c5.value)
return CallbackObserver(c1, c2, c3, c4, c5) {
observer(c1.value, c2.value, c3.value, c4.value, c5.value)
}
}
/** /**
* Map a transformation function over this cell. * Map a transformation function over this cell.
* *

View File

@ -3,21 +3,12 @@ package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.nopDisposable import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.AbstractDependency import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.ChangeEvent import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Observer
class ImmutableCell<T>(override val value: T) : AbstractDependency(), Cell<T> { class ImmutableCell<T>(override val value: T) : AbstractDependency(), Cell<T> {
override fun observe(callNow: Boolean, observer: Observer<T>): Disposable { override fun observeChange(observer: ChangeObserver<T>): Disposable = nopDisposable()
if (callNow) {
observer(ChangeEvent(value))
}
return nopDisposable()
}
override fun observe(observer: Observer<T>): Disposable = nopDisposable()
override fun emitDependencyChanged() { override fun emitDependencyChanged() {
error("StaticCell can't change.") error("ImmutableCell can't change.")
} }
} }

View File

@ -2,9 +2,9 @@ package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.unsafe.unsafeAssertNotNull import world.phantasmal.core.unsafe.unsafeAssertNotNull
import world.phantasmal.observable.CallbackObserver import world.phantasmal.observable.CallbackChangeObserver
import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Dependent import world.phantasmal.observable.Dependent
import world.phantasmal.observable.Observer
import world.phantasmal.observable.cell.AbstractDependentCell import world.phantasmal.observable.cell.AbstractDependentCell
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell import world.phantasmal.observable.cell.DependentCell
@ -55,28 +55,11 @@ abstract class AbstractDependentListCell<E> :
return unsafeAssertNotNull(_notEmpty) return unsafeAssertNotNull(_notEmpty)
} }
final override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable = final override fun observeChange(observer: ChangeObserver<List<E>>): Disposable =
observeList(callNow, observer as ListObserver<E>) observeListChange(observer)
override fun observeList(callNow: Boolean, observer: ListObserver<E>): Disposable { override fun observeListChange(observer: ListChangeObserver<E>): Disposable =
val observingCell = CallbackObserver(this, observer) CallbackChangeObserver(this, observer)
if (callNow) {
observer(
ListChangeEvent(
value,
listOf(ListChange.Structural(
index = 0,
prevSize = 0,
removed = emptyList(),
inserted = value,
)),
)
)
}
return observingCell
}
final override fun dependenciesChanged() { final override fun dependenciesChanged() {
val oldElements = value val oldElements = value

View File

@ -2,8 +2,8 @@ package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.unsafe.unsafeAssertNotNull import world.phantasmal.core.unsafe.unsafeAssertNotNull
import world.phantasmal.observable.CallbackObserver import world.phantasmal.observable.CallbackChangeObserver
import world.phantasmal.observable.Observer import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.cell.AbstractCell import world.phantasmal.observable.cell.AbstractCell
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell import world.phantasmal.observable.cell.DependentCell
@ -19,7 +19,7 @@ abstract class AbstractListCell<E> : AbstractCell<List<E>>(), ListCell<E> {
* its old value whenever a change event was emitted. * its old value whenever a change event was emitted.
*/ */
// TODO: Optimize this by using a weak reference to avoid copying when nothing references the // TODO: Optimize this by using a weak reference to avoid copying when nothing references the
// wrapper. // wrapper.
private var _elementsWrapper: DelegatingList<E>? = null private var _elementsWrapper: DelegatingList<E>? = null
protected val elementsWrapper: DelegatingList<E> protected val elementsWrapper: DelegatingList<E>
get() { get() {
@ -39,28 +39,11 @@ abstract class AbstractListCell<E> : AbstractCell<List<E>>(), ListCell<E> {
final override val notEmpty: Cell<Boolean> = !empty final override val notEmpty: Cell<Boolean> = !empty
final override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable = final override fun observeChange(observer: ChangeObserver<List<E>>): Disposable =
observeList(callNow, observer as ListObserver<E>) observeListChange(observer)
override fun observeList(callNow: Boolean, observer: ListObserver<E>): Disposable { override fun observeListChange(observer: ListChangeObserver<E>): Disposable =
val observingCell = CallbackObserver(this, observer) CallbackChangeObserver(this, observer)
if (callNow) {
observer(
ListChangeEvent(
value,
listOf(ListChange.Structural(
index = 0,
prevSize = 0,
removed = emptyList(),
inserted = value
)),
)
)
}
return observingCell
}
protected fun copyAndResetWrapper() { protected fun copyAndResetWrapper() {
_elementsWrapper?.backingList = elements.toList() _elementsWrapper?.backingList = elements.toList()

View File

@ -3,8 +3,7 @@ package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.nopDisposable import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.AbstractDependency import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.ChangeEvent import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Observer
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.cell import world.phantasmal.observable.cell.cell
import world.phantasmal.observable.cell.falseCell import world.phantasmal.observable.cell.falseCell
@ -20,31 +19,9 @@ class ImmutableListCell<E>(private val elements: List<E>) : AbstractDependency()
override fun get(index: Int): E = override fun get(index: Int): E =
elements[index] elements[index]
override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable { override fun observeChange(observer: ChangeObserver<List<E>>): Disposable = nopDisposable()
if (callNow) {
observer(ChangeEvent(value))
}
return nopDisposable() override fun observeListChange(observer: ListChangeObserver<E>): Disposable = nopDisposable()
}
override fun observe(observer: Observer<List<E>>): Disposable = nopDisposable()
override fun observeList(callNow: Boolean, observer: ListObserver<E>): Disposable {
if (callNow) {
observer(ListChangeEvent(
value,
listOf(ListChange.Structural(
index = 0,
prevSize = 0,
removed = emptyList(),
inserted = value,
)),
))
}
return nopDisposable()
}
override fun emitDependencyChanged() { override fun emitDependencyChanged() {
error("ImmutableListCell can't change.") error("ImmutableListCell can't change.")

View File

@ -14,7 +14,11 @@ interface ListCell<out E> : Cell<List<E>> {
operator fun get(index: Int): E = value[index] operator fun get(index: Int): E = value[index]
fun observeList(callNow: Boolean = false, observer: ListObserver<E>): Disposable /**
* List variant of [Cell.observeChange].
*/
// Exists solely because function parameters are invariant.
fun observeListChange(observer: ListChangeObserver<E>): Disposable
operator fun contains(element: @UnsafeVariance E): Boolean = element in value operator fun contains(element: @UnsafeVariance E): Boolean = element in value
} }

View File

@ -42,4 +42,4 @@ sealed class ListChange<out E> {
) : ListChange<E>() ) : ListChange<E>()
} }
typealias ListObserver<E> = (ListChangeEvent<E>) -> Unit typealias ListChangeObserver<E> = (ListChangeEvent<E>) -> Unit

View File

@ -16,7 +16,7 @@ interface ObservableTests : DependencyTests {
var changes = 0 var changes = 0
disposer.add( disposer.add(
p.observable.observe { p.observable.observeChange {
changes++ changes++
} }
) )
@ -37,7 +37,7 @@ interface ObservableTests : DependencyTests {
val p = createProvider() val p = createProvider()
var changes = 0 var changes = 0
val observer = p.observable.observe { val observer = p.observable.observeChange {
changes++ changes++
} }

View File

@ -1,6 +1,7 @@
package world.phantasmal.observable.cell package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.use import world.phantasmal.core.disposable.use
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.ObservableTests import world.phantasmal.observable.ObservableTests
import kotlin.test.* import kotlin.test.*
@ -15,6 +16,7 @@ interface CellTests : ObservableTests {
fun value_is_accessible_without_observers() = test { fun value_is_accessible_without_observers() = test {
val p = createProvider() val p = createProvider()
// We literally just test that accessing the value property doesn't throw or return null.
assertNotNull(p.observable.value) assertNotNull(p.observable.value)
} }
@ -22,106 +24,55 @@ interface CellTests : ObservableTests {
fun value_is_accessible_with_observers() = test { fun value_is_accessible_with_observers() = test {
val p = createProvider() val p = createProvider()
var observedValue: Any? = null disposer.add(p.observable.observeChange {})
disposer.add(p.observable.observe(callNow = true) { // We literally just test that accessing the value property doesn't throw or return null.
observedValue = it.value
})
assertNotNull(observedValue)
assertNotNull(p.observable.value) assertNotNull(p.observable.value)
} }
@Test @Test
fun propagates_changes_to_mapped_cell() = test { fun emits_no_change_event_until_changed() = test {
val p = createProvider() val p = createProvider()
val mapped = p.observable.map { it.hashCode() }
val initialValue = mapped.value
var observedValue: Int? = null var observedEvent: ChangeEvent<Any>? = null
disposer.add(mapped.observe { disposer.add(p.observable.observeChange { changeEvent ->
assertNull(observedValue) observedEvent = changeEvent
observedValue = it.value
}) })
assertNull(observedEvent)
p.emit() p.emit()
assertNotEquals(initialValue, mapped.value) assertNotNull(observedEvent)
assertEquals(mapped.value, observedValue)
}
@Test
fun propagates_changes_to_flat_mapped_cell() = test {
val p = createProvider()
val mapped = p.observable.flatMap { ImmutableCell(it.hashCode()) }
val initialValue = mapped.value
var observedValue: Int? = null
disposer.add(mapped.observe {
assertNull(observedValue)
observedValue = it.value
})
p.emit()
assertNotEquals(initialValue, mapped.value)
assertEquals(mapped.value, observedValue)
} }
@Test @Test
fun emits_correct_value_in_change_events() = test { fun emits_correct_value_in_change_events() = test {
val p = createProvider() val p = createProvider()
var prevValue: Any?
var observedValue: Any? = null var observedValue: Any? = null
disposer.add(p.observable.observe { disposer.add(p.observable.observeChange { changeEvent ->
assertNull(observedValue) assertNull(observedValue)
observedValue = it.value observedValue = changeEvent.value
}) })
repeat(3) { repeat(3) {
prevValue = observedValue
observedValue = null observedValue = null
p.emit() p.emit()
// We should have observed a value, it should be different from the previous value, and
// it should be equal to the cell's current value.
assertNotNull(observedValue) assertNotNull(observedValue)
assertNotEquals(prevValue, observedValue)
assertEquals(p.observable.value, observedValue) assertEquals(p.observable.value, observedValue)
} }
} }
/**
* When [Cell.observe] is called with callNow = true, it should call the observer immediately.
* Otherwise it should only call the observer when it changes.
*/
@Test
fun respects_call_now_argument() = test {
val p = createProvider()
var changes = 0
// Test callNow = false
p.observable.observe(callNow = false) {
changes++
}.use {
p.emit()
assertEquals(1, changes)
}
// Test callNow = true
changes = 0
p.observable.observe(callNow = true) {
changes++
}.use {
p.emit()
assertEquals(2, changes)
}
}
/** /**
* [Cell.value] should correctly reflect changes even when the [Cell] has no observers. * [Cell.value] should correctly reflect changes even when the [Cell] has no observers.
* Typically this means that the cell's value is not updated in real time, only when it is * Typically this means that the cell's value is not updated in real time, only when it is
@ -148,6 +99,63 @@ interface CellTests : ObservableTests {
} }
} }
//
// CellUtils Tests
//
@Test
fun propagates_changes_to_observeNow_observers() = test {
val p = createProvider()
var changes = 0
p.observable.observeNow {
changes++
}.use {
p.emit()
assertEquals(2, changes)
}
}
@Test
fun propagates_changes_to_mapped_cell() = test {
val p = createProvider()
val mapped = p.observable.map { it.hashCode() }
val initialValue = mapped.value
var observedValue: Int? = null
disposer.add(mapped.observeChange { changeEvent ->
assertNull(observedValue)
observedValue = changeEvent.value
})
p.emit()
assertNotEquals(initialValue, mapped.value)
assertEquals(mapped.value, observedValue)
}
@Test
fun propagates_changes_to_flat_mapped_cell() = test {
val p = createProvider()
val mapped = p.observable.flatMap { ImmutableCell(it.hashCode()) }
val initialValue = mapped.value
var observedValue: Int? = null
disposer.add(mapped.observeChange {
assertNull(observedValue)
observedValue = it.value
})
p.emit()
assertNotEquals(initialValue, mapped.value)
assertEquals(mapped.value, observedValue)
}
interface Provider : ObservableTests.Provider { interface Provider : ObservableTests.Provider {
override val observable: Cell<Any> override val observable: Cell<Any>
} }

View File

@ -19,7 +19,7 @@ interface CellWithDependenciesTests : CellTests {
var observedChanges = 0 var observedChanges = 0
disposer.add(leaf.observe { observedChanges++ }) disposer.add(leaf.observeChange { observedChanges++ })
// Change root, which results in both branches changing and thus two dependencies of leaf // Change root, which results in both branches changing and thus two dependencies of leaf
// changing. // changing.
@ -47,7 +47,7 @@ interface CellWithDependenciesTests : CellTests {
assertTrue(dependency.publicDependents.isEmpty()) assertTrue(dependency.publicDependents.isEmpty())
disposer.add(cell.observe { }) disposer.add(cell.observeChange { })
assertEquals(1, dependency.publicDependents.size) assertEquals(1, dependency.publicDependents.size)
} }

View File

@ -13,7 +13,7 @@ class ChangeTests : ObservableTestSuite {
val dependent = dependency.map { 2 * it } val dependent = dependency.map { 2 * it }
var dependentObservedValue: Int? = null var dependentObservedValue: Int? = null
disposer.add(dependent.observe { dependentObservedValue = it.value }) disposer.add(dependent.observeChange { dependentObservedValue = it.value })
assertFails { assertFails {
change { change {

View File

@ -7,24 +7,21 @@ import kotlin.test.assertEquals
class ImmutableCellTests : ObservableTestSuite { class ImmutableCellTests : ObservableTestSuite {
@Test @Test
fun observing_it_should_never_create_leaks() = test { fun observing_it_never_creates_leaks() = test {
val cell = ImmutableCell("test value") val cell = ImmutableCell("test value")
TrackedDisposable.checkNoLeaks { TrackedDisposable.checkNoLeaks {
// We never call dispose on the returned disposables. // We never call dispose on the returned disposable.
cell.observe {} cell.observeChange {}
cell.observe(callNow = false) {}
cell.observe(callNow = true) {}
} }
} }
@Test @Test
fun observe_respects_callNow() = test { fun observeNow_calls_the_observer_once() = test {
val cell = ImmutableCell("test value") val cell = ImmutableCell("test value")
var calls = 0 var calls = 0
cell.observe(callNow = false) { calls++ } cell.observeNow { calls++ }
cell.observe(callNow = true) { calls++ }
assertEquals(1, calls) assertEquals(1, calls)
} }

View File

@ -14,7 +14,7 @@ interface MutableCellTests<T : Any> : CellTests {
var observedValue: Any? = null var observedValue: Any? = null
disposer.add(p.observable.observe { disposer.add(p.observable.observeChange {
assertNull(observedValue) assertNull(observedValue)
observedValue = it.value observedValue = it.value
}) })

View File

@ -46,10 +46,10 @@ class FilteredListCellTests : ListCellTests {
var changes = 0 var changes = 0
var listChanges = 0 var listChanges = 0
disposer.add(list.observe { disposer.add(list.observeChange {
changes++ changes++
}) })
disposer.add(list.observeList { disposer.add(list.observeListChange {
listChanges++ listChanges++
}) })
@ -74,7 +74,7 @@ class FilteredListCellTests : ListCellTests {
val list = FilteredListCell(dep, predicate = { it % 2 == 0 }) val list = FilteredListCell(dep, predicate = { it % 2 == 0 })
var event: ListChangeEvent<Int>? = null var event: ListChangeEvent<Int>? = null
disposer.add(list.observeList { disposer.add(list.observeListChange {
assertNull(event) assertNull(event)
event = it event = it
}) })
@ -128,7 +128,7 @@ class FilteredListCellTests : ListCellTests {
val list = FilteredListCell(dep, predicate = { it.value % 2 == 0 }) val list = FilteredListCell(dep, predicate = { it.value % 2 == 0 })
var event: ListChangeEvent<SimpleCell<Int>>? = null var event: ListChangeEvent<SimpleCell<Int>>? = null
disposer.add(list.observeList { disposer.add(list.observeListChange {
assertNull(event) assertNull(event)
event = it event = it
}) })

View File

@ -1,43 +1,29 @@
package world.phantasmal.observable.cell.list package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.TrackedDisposable import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.observable.test.ObservableTestSuite import world.phantasmal.observable.test.ObservableTestSuite
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class ImmutableListCellTests : ObservableTestSuite { class ImmutableListCellTests : ObservableTestSuite {
@Test @Test
fun observing_it_should_never_create_leaks() = test { fun observing_it_never_creates_leaks() = test {
val listCell = ImmutableListCell(listOf(1, 2, 3)) val listCell = ImmutableListCell(listOf(1, 2, 3))
TrackedDisposable.checkNoLeaks { TrackedDisposable.checkNoLeaks {
// We never call dispose on the returned disposables. // We never call dispose on the returned disposables.
listCell.observe {} listCell.observeChange {}
listCell.observe(callNow = false) {} listCell.observeListChange {}
listCell.observe(callNow = true) {}
listCell.observeList(callNow = false) {}
listCell.observeList(callNow = true) {}
} }
} }
@Test @Test
fun observe_respects_callNow() = test { fun observeNow_calls_the_observer_once() = test {
val listCell = ImmutableListCell(listOf(1, 2, 3)) val listCell = ImmutableListCell(listOf(1, 2, 3))
var calls = 0 var calls = 0
listCell.observe(callNow = false) { calls++ } listCell.observeNow { calls++ }
listCell.observe(callNow = true) { calls++ }
assertEquals(1, calls)
}
@Test
fun observeList_respects_callNow() = test {
val listCell = ImmutableListCell(listOf(1, 2, 3))
var calls = 0
listCell.observeList(callNow = false) { calls++ }
listCell.observeList(callNow = true) { calls++ }
assertEquals(1, calls) assertEquals(1, calls)
} }

View File

@ -16,6 +16,8 @@ interface ListCellTests : CellTests {
fun list_value_is_accessible_without_observers() = test { fun list_value_is_accessible_without_observers() = test {
val p = createListProvider(empty = false) val p = createListProvider(empty = false)
// We literally just test that accessing the value property doesn't throw or return the
// wrong list.
assertTrue(p.observable.value.isNotEmpty()) assertTrue(p.observable.value.isNotEmpty())
} }
@ -23,14 +25,28 @@ interface ListCellTests : CellTests {
fun list_value_is_accessible_with_observers() = test { fun list_value_is_accessible_with_observers() = test {
val p = createListProvider(empty = false) val p = createListProvider(empty = false)
var observedValue: List<*>? = null disposer.add(p.observable.observeListChange {})
disposer.add(p.observable.observe(callNow = true) { // We literally just test that accessing the value property doesn't throw or return the
observedValue = it.value // wrong list.
assertTrue(p.observable.value.isNotEmpty())
}
@Test
fun emits_no_list_change_event_until_changed() = test {
val p = createListProvider(empty = false)
var observedEvent: ListChangeEvent<Any>? = null
disposer.add(p.observable.observeListChange { listChangeEvent ->
observedEvent = listChangeEvent
}) })
assertTrue(observedValue!!.isNotEmpty()) assertNull(observedEvent)
assertTrue(p.observable.value.isNotEmpty())
p.emit()
assertNotNull(observedEvent)
} }
@Test @Test
@ -40,7 +56,7 @@ interface ListCellTests : CellTests {
var event: ListChangeEvent<*>? = null var event: ListChangeEvent<*>? = null
disposer.add( disposer.add(
p.observable.observeList { p.observable.observeListChange {
assertNull(event) assertNull(event)
event = it event = it
} }
@ -64,7 +80,7 @@ interface ListCellTests : CellTests {
var observedSize: Int? = null var observedSize: Int? = null
disposer.add( disposer.add(
p.observable.size.observe { p.observable.size.observeChange {
assertNull(observedSize) assertNull(observedSize)
observedSize = it.value observedSize = it.value
} }
@ -102,7 +118,7 @@ interface ListCellTests : CellTests {
var observedValue: Int? = null var observedValue: Int? = null
disposer.add(fold.observe { disposer.add(fold.observeChange {
assertNull(observedValue) assertNull(observedValue)
observedValue = it.value observedValue = it.value
}) })
@ -127,7 +143,7 @@ interface ListCellTests : CellTests {
var observedValue: Int? = null var observedValue: Int? = null
disposer.add(sum.observe { disposer.add(sum.observeChange {
assertNull(observedValue) assertNull(observedValue)
observedValue = it.value observedValue = it.value
}) })
@ -152,7 +168,7 @@ interface ListCellTests : CellTests {
var event: ListChangeEvent<*>? = null var event: ListChangeEvent<*>? = null
disposer.add(filtered.observeList { disposer.add(filtered.observeListChange {
assertNull(event) assertNull(event)
event = it event = it
}) })
@ -177,7 +193,7 @@ interface ListCellTests : CellTests {
var observedValue: Any? = null var observedValue: Any? = null
disposer.add(firstOrNull.observe { disposer.add(firstOrNull.observeChange {
assertNull(observedValue) assertNull(observedValue)
observedValue = it.value observedValue = it.value
}) })

View File

@ -16,7 +16,7 @@ interface MutableListCellTests<T : Any> : ListCellTests, MutableCellTests<List<T
var changeEvent: ListChangeEvent<T>? = null var changeEvent: ListChangeEvent<T>? = null
disposer.add(p.observable.observeList { disposer.add(p.observable.observeListChange {
assertNull(changeEvent) assertNull(changeEvent)
changeEvent = it changeEvent = it
}) })

View File

@ -245,7 +245,7 @@ class SimpleListCellTests : MutableListCellTests<Int> {
var event: ListChangeEvent<SimpleCell<String>>? = null var event: ListChangeEvent<SimpleCell<String>>? = null
disposer.add(list.observeList { disposer.add(list.observeListChange {
assertNull(event) assertNull(event)
event = it event = it
}) })

View File

@ -1,7 +1,7 @@
package world.phantasmal.web.application.widgets package world.phantasmal.web.application.widgets
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.Observable import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.nullCell import world.phantasmal.observable.cell.nullCell
import world.phantasmal.observable.cell.trueCell import world.phantasmal.observable.cell.trueCell
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
@ -12,7 +12,7 @@ import world.phantasmal.webui.widgets.Control
class PwToolButton( class PwToolButton(
private val tool: PwToolType, private val tool: PwToolType,
private val toggled: Observable<Boolean>, private val toggled: Cell<Boolean>,
private val onMouseDown: () -> Unit, private val onMouseDown: () -> Unit,
) : Control(visible = trueCell(), enabled = trueCell(), tooltip = nullCell()) { ) : Control(visible = trueCell(), enabled = trueCell(), tooltip = nullCell()) {
private val inputId = "pw-application-pw-tool-button-${tool.name.lowercase()}" private val inputId = "pw-application-pw-tool-button-${tool.name.lowercase()}"
@ -25,7 +25,7 @@ class PwToolButton(
type = "radio" type = "radio"
id = inputId id = inputId
name = "pw-application-pw-tool-button" name = "pw-application-pw-tool-button"
observe(toggled) { checked = it } observeNow(toggled) { checked = it }
} }
label { label {
htmlFor = inputId htmlFor = inputId

View File

@ -15,7 +15,7 @@ open class PathAwareTabContainerController<T : PathAwareTab>(
tabs: List<T>, tabs: List<T>,
) : TabContainerController<T>(tabs) { ) : TabContainerController<T>(tabs) {
init { init {
observe(uiStore.path) { path -> observeNow(uiStore.path) { path ->
if (uiStore.currentTool.value == tool) { if (uiStore.currentTool.value == tool) {
tabs.find { path.startsWith(it.path) }?.let { tabs.find { path.startsWith(it.path) }?.let {
setActiveTab(it, replaceUrl = true) setActiveTab(it, replaceUrl = true)

View File

@ -82,7 +82,7 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
window.disposableListener("keydown", ::dispatchGlobalKeyDown), window.disposableListener("keydown", ::dispatchGlobalKeyDown),
) )
observe(applicationUrl.url) { setDataFromUrl(it) } observeNow(applicationUrl.url) { setDataFromUrl(it) }
} }
fun setCurrentTool(tool: PwToolType) { fun setCurrentTool(tool: PwToolType) {
@ -126,12 +126,12 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
} }
return Disposer( return Disposer(
value.observe { value.observeChange {
if (it.value != param.value) { if (it.value != param.value) {
setParameter(tool, path, param, it.value, replaceUrl = false) setParameter(tool, path, param, it.value, replaceUrl = false)
} }
}, },
param.observe { onChange(it.value) }, param.observeChange { onChange(it.value) },
) )
} }

View File

@ -64,7 +64,7 @@ class DockWidget(
style.width = "" style.width = ""
style.height = "" style.height = ""
addDisposable(size.observe { (size) -> addDisposable(size.observeChange { (size) ->
goldenLayout.updateSize(size.width, size.height) goldenLayout.updateSize(size.width, size.height)
}) })
} }

View File

@ -12,7 +12,7 @@ class RendererWidget(
div { div {
className = "pw-core-renderer" className = "pw-core-renderer"
observe(selfOrAncestorVisible) { visible -> observeNow(selfOrAncestorVisible) { visible ->
if (visible) { if (visible) {
renderer.startRendering() renderer.startRendering()
} else { } else {
@ -20,7 +20,7 @@ class RendererWidget(
} }
} }
addDisposable(size.observe { (size) -> addDisposable(size.observeChange { (size) ->
renderer.setSize(size.width.toInt(), size.height.toInt()) renderer.setSize(size.width.toInt(), size.height.toInt())
}) })

View File

@ -31,7 +31,7 @@ class HuntMethodStore(
/** Hunting methods supported by the current server. */ /** Hunting methods supported by the current server. */
val methods: ListCell<HuntMethodModel> by lazy { val methods: ListCell<HuntMethodModel> by lazy {
observe(uiStore.server) { _methodsStatus.load() } observeNow(uiStore.server) { _methodsStatus.load() }
_methods _methods
} }

View File

@ -55,7 +55,7 @@ class HuntOptimizerStore(
private var wantedItemsPersistenceObserver: Disposable? = null private var wantedItemsPersistenceObserver: Disposable? = null
val huntableItems: ListCell<ItemType> by lazy { val huntableItems: ListCell<ItemType> by lazy {
observe(uiStore.server) { server -> observeNow(uiStore.server) { server ->
_huntableItems.clear() _huntableItems.clear()
scope.launch { scope.launch {
@ -71,12 +71,12 @@ class HuntOptimizerStore(
} }
val wantedItems: ListCell<WantedItemModel> by lazy { val wantedItems: ListCell<WantedItemModel> by lazy {
observe(uiStore.server) { loadWantedItems(it) } observeNow(uiStore.server) { loadWantedItems(it) }
_wantedItems _wantedItems
} }
val optimizationResult: Cell<OptimizationResultModel> by lazy { val optimizationResult: Cell<OptimizationResultModel> by lazy {
observe(wantedItems, huntMethodStore.methods) { wantedItems, huntMethods -> observeNow(wantedItems, huntMethodStore.methods) { wantedItems, huntMethods ->
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
_optimizationResult.value = optimize(wantedItems, huntMethods) _optimizationResult.value = optimize(wantedItems, huntMethods)
} }
@ -114,7 +114,7 @@ class HuntOptimizerStore(
_wantedItems.replaceAll(wantedItems) _wantedItems.replaceAll(wantedItems)
// Wanted items are loaded, start observing them and persist whenever they change. // Wanted items are loaded, start observing them and persist whenever they change.
wantedItemsPersistenceObserver = _wantedItems.observe { wantedItemsPersistenceObserver = _wantedItems.observeChange {
val items = it.value val items = it.value
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {

View File

@ -11,8 +11,8 @@ class DestinationInstance(
) : Instance<QuestObjectModel>(entity, mesh, instanceIndex) { ) : Instance<QuestObjectModel>(entity, mesh, instanceIndex) {
init { init {
addDisposables( addDisposables(
entity.destinationPosition.observe { updateMatrix() }, entity.destinationPosition.observeChange { updateMatrix() },
entity.destinationRotationY.observe { updateMatrix() }, entity.destinationRotationY.observeChange { updateMatrix() },
) )
} }

View File

@ -13,14 +13,14 @@ class EntityInstance(
) : Instance<QuestEntityModel<*, *>>(entity, mesh, instanceIndex) { ) : Instance<QuestEntityModel<*, *>>(entity, mesh, instanceIndex) {
init { init {
if (entity is QuestObjectModel) { if (entity is QuestObjectModel) {
addDisposable(entity.model.observe(callNow = false) { addDisposable(entity.model.observeChange {
modelChanged(this.instanceIndex) modelChanged(this.instanceIndex)
}) })
} }
addDisposables( addDisposables(
entity.worldPosition.observe { updateMatrix() }, entity.worldPosition.observeChange { updateMatrix() },
entity.worldRotation.observe { updateMatrix() }, entity.worldRotation.observeChange { updateMatrix() },
) )
} }

View File

@ -5,6 +5,7 @@ import mu.KotlinLogging
import org.khronos.webgl.Float32Array import org.khronos.webgl.Float32Array
import world.phantasmal.core.disposable.DisposableSupervisedScope import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.psolib.fileFormats.quest.EntityType import world.phantasmal.psolib.fileFormats.quest.EntityType
import world.phantasmal.web.core.rendering.disposeObject3DResources import world.phantasmal.web.core.rendering.disposeObject3DResources
import world.phantasmal.web.externals.three.* import world.phantasmal.web.externals.three.*
@ -92,13 +93,13 @@ class EntityMeshManager(
} }
init { init {
observe(questEditorStore.highlightedEntity) { entity -> observeNow(questEditorStore.highlightedEntity) { entity ->
// getEntityInstance can return null at this point because the entity mesh might not be // getEntityInstance can return null at this point because the entity mesh might not be
// loaded yet. // loaded yet.
markHighlighted(entity?.let(::getEntityInstance)) markHighlighted(entity?.let(::getEntityInstance))
} }
observe(questEditorStore.selectedEntity) { entity -> observeNow(questEditorStore.selectedEntity) { entity ->
// getEntityInstance can return null at this point because the entity mesh might not be // getEntityInstance can return null at this point because the entity mesh might not be
// loaded yet. // loaded yet.
markSelected(entity?.let(::getEntityInstance)) markSelected(entity?.let(::getEntityInstance))
@ -135,7 +136,9 @@ class EntityMeshManager(
destinationInstanceContainer.addInstance(entity) destinationInstanceContainer.addInstance(entity)
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Do nothing. logger.trace(e) {
"Mesh loading for entity of type ${entity.type} cancelled."
}
} catch (e: Throwable) { } catch (e: Throwable) {
logger.error(e) { logger.error(e) {
"Couldn't load mesh for entity of type ${entity.type}." "Couldn't load mesh for entity of type ${entity.type}."
@ -228,12 +231,12 @@ class EntityMeshManager(
newInstance.entity is QuestObjectModel && newInstance.entity is QuestObjectModel &&
newInstance.entity.hasDestination newInstance.entity.hasDestination
) { ) {
warpLineDisposer.add(newInstance.entity.worldPosition.observe(callNow = true) { warpLineDisposer.add(newInstance.entity.worldPosition.observeNow {
warpLineBufferAttribute.setXYZ(0, it.value.x, it.value.y, it.value.z) warpLineBufferAttribute.setXYZ(0, it.x, it.y, it.z)
warpLineBufferAttribute.needsUpdate = true warpLineBufferAttribute.needsUpdate = true
}) })
warpLineDisposer.add(newInstance.entity.destinationPosition.observe(callNow = true) { warpLineDisposer.add(newInstance.entity.destinationPosition.observeNow {
warpLineBufferAttribute.setXYZ(1, it.value.x, it.value.y, it.value.z) warpLineBufferAttribute.setXYZ(1, it.x, it.y, it.z)
warpLineBufferAttribute.needsUpdate = true warpLineBufferAttribute.needsUpdate = true
}) })
warpLines.visible = true warpLines.visible = true

View File

@ -14,14 +14,14 @@ class QuestEditorMeshManager(
renderContext: QuestRenderContext, renderContext: QuestRenderContext,
) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) { ) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) {
init { init {
observe( observeNow(
questEditorStore.currentQuest, questEditorStore.currentQuest,
questEditorStore.currentAreaVariant, questEditorStore.currentAreaVariant,
) { quest, areaVariant -> ) { quest, areaVariant ->
loadAreaMeshes(quest?.episode, areaVariant) loadAreaMeshes(quest?.episode, areaVariant)
} }
observe( observeNow(
questEditorStore.currentQuest, questEditorStore.currentQuest,
questEditorStore.currentArea, questEditorStore.currentArea,
questEditorStore.selectedEvent.flatMapNull { it?.wave }, questEditorStore.selectedEvent.flatMapNull { it?.wave },
@ -39,7 +39,7 @@ class QuestEditorMeshManager(
) )
} }
observe( observeNow(
questEditorStore.currentQuest, questEditorStore.currentQuest,
questEditorStore.currentArea, questEditorStore.currentArea,
) { quest, area -> ) { quest, area ->
@ -54,7 +54,7 @@ class QuestEditorMeshManager(
) )
} }
observe(questEditorStore.showCollisionGeometry) { observeNow(questEditorStore.showCollisionGeometry) {
renderContext.collisionGeometryVisible = it renderContext.collisionGeometryVisible = it
renderContext.renderGeometryVisible = !it renderContext.renderGeometryVisible = !it
} }

View File

@ -5,10 +5,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.DisposableSupervisedScope import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.psolib.Episode
import world.phantasmal.observable.cell.list.ListCell import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.ListChange import world.phantasmal.observable.cell.list.ListChange
import world.phantasmal.observable.cell.list.ListChangeEvent import world.phantasmal.observable.cell.list.ListChangeEvent
import world.phantasmal.psolib.Episode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.AreaVariantModel import world.phantasmal.web.questEditor.models.AreaVariantModel
@ -55,7 +55,9 @@ abstract class QuestMeshManager protected constructor(
npcObserver?.dispose() npcObserver?.dispose()
npcMeshManager.removeAll() npcMeshManager.removeAll()
npcObserver = npcs.observeList(callNow = true, ::npcsChanged) npcs.value.forEach(npcMeshManager::add)
npcObserver = npcs.observeListChange(::npcsChanged)
} }
} }
@ -65,7 +67,9 @@ abstract class QuestMeshManager protected constructor(
objectObserver?.dispose() objectObserver?.dispose()
objectMeshManager.removeAll() objectMeshManager.removeAll()
objectObserver = objects.observeList(callNow = true, ::objectsChanged) objects.value.forEach(objectMeshManager::add)
objectObserver = objects.observeListChange(::objectsChanged)
} }
} }

View File

@ -39,7 +39,7 @@ class QuestRenderer(
), ),
) )
observe(questEditorStore.currentQuest) { inputManager.resetCamera() } observeNow(questEditorStore.currentQuest) { inputManager.resetCamera() }
observe(questEditorStore.currentAreaVariant) { inputManager.resetCamera() } observeNow(questEditorStore.currentAreaVariant) { inputManager.resetCamera() }
} }
} }

View File

@ -84,8 +84,8 @@ class QuestInputManager(
stateContext = StateContext(questEditorStore, renderContext, cameraInputManager) stateContext = StateContext(questEditorStore, renderContext, cameraInputManager)
state = IdleState(stateContext, entityManipulationEnabled) state = IdleState(stateContext, entityManipulationEnabled)
observe(questEditorStore.selectedEntity) { returnToIdleState() } observeNow(questEditorStore.selectedEntity) { returnToIdleState() }
observe(questEditorStore.questEditingEnabled) { entityManipulationEnabled = it } observeNow(questEditorStore.questEditingEnabled) { entityManipulationEnabled = it }
pointerTrap.className = "pw-quest-editor-input-manager-pointer-trap" pointerTrap.className = "pw-quest-editor-input-manager-pointer-trap"
pointerTrap.hidden = true pointerTrap.hidden = true

View File

@ -55,11 +55,11 @@ class AsmStore(
val problems: ListCell<AssemblyProblem> = asmAnalyser.problems val problems: ListCell<AssemblyProblem> = asmAnalyser.problems
init { init {
observe(questEditorStore.currentQuest) { quest -> observeNow(questEditorStore.currentQuest) { quest ->
setTextModel(quest, inlineStackArgs.value) setTextModel(quest, inlineStackArgs.value)
} }
observe(inlineStackArgs) { inlineStackArgs -> observeNow(inlineStackArgs) { inlineStackArgs ->
// Ensure we have the most up-to-date bytecode before we disassemble it again. // Ensure we have the most up-to-date bytecode before we disassemble it again.
if (setBytecodeIrTimeout != null) { if (setBytecodeIrTimeout != null) {
setBytecodeIr() setBytecodeIr()
@ -72,7 +72,7 @@ class AsmStore(
scope.launch { questEditorStore.setMapDesignations(it) } scope.launch { questEditorStore.setMapDesignations(it) }
} }
observe(problems) { problems -> observeNow(problems) { problems ->
textModel.value?.let { model -> textModel.value?.let { model ->
val markers = Array<IMarkerData>(problems.size) { val markers = Array<IMarkerData>(problems.size) {
val problem = problems[it] val problem = problems[it]

View File

@ -94,13 +94,13 @@ class QuestEditorStore(
}, },
) )
observe(uiStore.currentTool) { tool -> observeNow(uiStore.currentTool) { tool ->
if (tool == PwToolType.QuestEditor) { if (tool == PwToolType.QuestEditor) {
makeMainUndoCurrent() makeMainUndoCurrent()
} }
} }
observe(currentQuest.flatMap { it?.npcs ?: emptyListCell() }) { npcs -> observeNow(currentQuest.flatMap { it?.npcs ?: emptyListCell() }) { npcs ->
val selected = selectedEntity.value val selected = selectedEntity.value
if (selected is QuestNpcModel && selected !in npcs) { if (selected is QuestNpcModel && selected !in npcs) {
@ -108,7 +108,7 @@ class QuestEditorStore(
} }
} }
observe(currentQuest.flatMap { it?.objects ?: emptyListCell() }) { objects -> observeNow(currentQuest.flatMap { it?.objects ?: emptyListCell() }) { objects ->
val selected = selectedEntity.value val selected = selectedEntity.value
if (selected is QuestObjectModel && selected !in objects) { if (selected is QuestObjectModel && selected !in objects) {

View File

@ -53,7 +53,7 @@ class TextModelUndo(
init { init {
undoManager.addUndo(this) undoManager.addUndo(this)
modelObserver = model.observe(callNow = true) { onModelChange(it.value) } modelObserver = model.observeNow(::onModelChange)
} }
override fun dispose() { override fun dispose() {

View File

@ -32,11 +32,11 @@ class AsmEditorWidget(private val ctrl: AsmEditorController) : Widget() {
addDisposable(disposable { editor.dispose() }) addDisposable(disposable { editor.dispose() })
observe(ctrl.textModel) { editor.setModel(it) } observeNow(ctrl.textModel) { editor.setModel(it) }
observe(ctrl.readOnly) { editor.updateOptions(obj { readOnly = it }) } observeNow(ctrl.readOnly) { editor.updateOptions(obj { readOnly = it }) }
addDisposable(size.observe { (size) -> addDisposable(size.observeChange { (size) ->
if (size.width > .0 && size.height > .0) { if (size.width > .0 && size.height > .0) {
editor.layout(obj { editor.layout(obj {
width = size.width width = size.width
@ -65,7 +65,7 @@ class AsmEditorWidget(private val ctrl: AsmEditorController) : Widget() {
editor.trigger( editor.trigger(
source = AsmEditorWidget::class.simpleName, source = AsmEditorWidget::class.simpleName,
handlerId = "undo", handlerId = "undo",
payload = undefined payload = undefined,
) )
} }
@ -74,7 +74,7 @@ class AsmEditorWidget(private val ctrl: AsmEditorController) : Widget() {
editor.trigger( editor.trigger(
source = AsmEditorWidget::class.simpleName, source = AsmEditorWidget::class.simpleName,
handlerId = "redo", handlerId = "redo",
payload = undefined payload = undefined,
) )
} }

View File

@ -100,7 +100,7 @@ class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled
val inputValue = mutableCell(value.value) val inputValue = mutableCell(value.value)
var timeout = -1 var timeout = -1
observe(value) { observeNow(value) {
if (timeout == -1) { if (timeout == -1) {
timeout = window.setTimeout({ timeout = window.setTimeout({
inputValue.value = value.value inputValue.value = value.value

View File

@ -36,7 +36,7 @@ class EventWidget(
} }
} }
observe(isSelected) { observeNow(isSelected) {
if (it) { if (it) {
scrollIntoView(obj<ScrollIntoViewOptions> { scrollIntoView(obj<ScrollIntoViewOptions> {
behavior = ScrollBehavior.SMOOTH behavior = ScrollBehavior.SMOOTH

View File

@ -56,14 +56,14 @@ class MeshRenderer(
) )
init { init {
observe(viewerStore.currentNinjaGeometry) { rebuildMesh(resetCamera = true) } observeNow(viewerStore.currentNinjaGeometry) { rebuildMesh(resetCamera = true) }
observe(viewerStore.currentTextures) { rebuildMesh(resetCamera = true) } observeNow(viewerStore.currentTextures) { rebuildMesh(resetCamera = true) }
observe(viewerStore.applyTextures) { rebuildMesh(resetCamera = false) } observeNow(viewerStore.applyTextures) { rebuildMesh(resetCamera = false) }
observe(viewerStore.currentNinjaMotion, ::ninjaMotionChanged) observeNow(viewerStore.currentNinjaMotion, ::ninjaMotionChanged)
observe(viewerStore.showSkeleton) { skeletonHelper?.visible = it } observeNow(viewerStore.showSkeleton) { skeletonHelper?.visible = it }
observe(viewerStore.animationPlaying, ::animationPlayingChanged) observeNow(viewerStore.animationPlaying, ::animationPlayingChanged)
observe(viewerStore.frameRate, ::frameRateChanged) observeNow(viewerStore.frameRate, ::frameRateChanged)
observe(viewerStore.frame, ::frameChanged) observeNow(viewerStore.frame, ::frameChanged)
} }
override fun dispose() { override fun dispose() {

View File

@ -46,7 +46,7 @@ class TextureRenderer(
)) ))
init { init {
observe(store.currentTextures) { observeNow(store.currentTextures) {
texturesChanged(it.filterNotNull()) texturesChanged(it.filterNotNull())
} }
} }

View File

@ -1,5 +1,6 @@
package world.phantasmal.web.questEditor.controllers package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.web.questEditor.models.QuestEventActionModel import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.test.WebTestSuite import world.phantasmal.web.test.WebTestSuite
@ -115,9 +116,9 @@ class EventsControllerTests : WebTestSuite {
// We test the observed value instead of the cell's value property. // We test the observed value instead of the cell's value property.
var canGoToEventValue: Boolean? = null var canGoToEventValue: Boolean? = null
disposer.add(canGoToEvent.observe(callNow = true) { disposer.add(canGoToEvent.observeNow {
assertNull(canGoToEventValue) assertNull(canGoToEventValue)
canGoToEventValue = it.value canGoToEventValue = it
}) })
assertEquals(true, canGoToEventValue) assertEquals(true, canGoToEventValue)

View File

@ -4,8 +4,9 @@ import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.observable.Observable import world.phantasmal.observable.Observable
import world.phantasmal.observable.Observer
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.observable.observe
abstract class DisposableContainer : TrackedDisposable() { abstract class DisposableContainer : TrackedDisposable() {
private val disposer = Disposer() private val disposer = Disposer()
@ -29,85 +30,55 @@ abstract class DisposableContainer : TrackedDisposable() {
disposer.remove(disposable, dispose) disposer.remove(disposable, dispose)
} }
protected fun <V1> observe(observable: Observable<V1>, operation: (V1) -> Unit) { protected fun <T> observe(
addDisposable( o1: Observable<T>,
if (observable is Cell<V1>) { observer: (T) -> Unit,
observable.observe(callNow = true) { operation(it.value) } ) {
} else { addDisposable(o1.observe(observer))
observable.observe { operation(it.value) }
}
)
} }
protected fun <V1, V2> observe( protected fun <T> observeNow(
v1: Cell<V1>, c1: Cell<T>,
v2: Cell<V2>, observer: (T) -> Unit,
operation: (V1, V2) -> Unit,
) { ) {
val observer: Observer<*> = { addDisposable(c1.observeNow(observer))
operation(v1.value, v2.value)
}
addDisposables(
v1.observe(observer),
v2.observe(observer),
)
operation(v1.value, v2.value)
} }
protected fun <V1, V2, V3> observe( protected fun <T1, T2> observeNow(
v1: Cell<V1>, c1: Cell<T1>,
v2: Cell<V2>, c2: Cell<T2>,
v3: Cell<V3>, observer: (T1, T2) -> Unit,
operation: (V1, V2, V3) -> Unit,
) { ) {
val observer: Observer<*> = { addDisposable(world.phantasmal.observable.cell.observeNow(c1, c2, observer))
operation(v1.value, v2.value, v3.value)
}
addDisposables(
v1.observe(observer),
v2.observe(observer),
v3.observe(observer),
)
operation(v1.value, v2.value, v3.value)
} }
protected fun <V1, V2, V3, V4> observe( protected fun <T1, T2, T3> observeNow(
v1: Cell<V1>, c1: Cell<T1>,
v2: Cell<V2>, c2: Cell<T2>,
v3: Cell<V3>, c3: Cell<T3>,
v4: Cell<V4>, observer: (T1, T2, T3) -> Unit,
operation: (V1, V2, V3, V4) -> Unit,
) { ) {
val observer: Observer<*> = { addDisposable(world.phantasmal.observable.cell.observeNow(c1, c2, c3, observer))
operation(v1.value, v2.value, v3.value, v4.value)
}
addDisposables(
v1.observe(observer),
v2.observe(observer),
v3.observe(observer),
v4.observe(observer),
)
operation(v1.value, v2.value, v3.value, v4.value)
} }
protected fun <V1, V2, V3, V4, V5> observe( protected fun <T1, T2, T3, T4> observeNow(
v1: Cell<V1>, c1: Cell<T1>,
v2: Cell<V2>, c2: Cell<T2>,
v3: Cell<V3>, c3: Cell<T3>,
v4: Cell<V4>, c4: Cell<T4>,
v5: Cell<V5>, observer: (T1, T2, T3, T4) -> Unit,
operation: (V1, V2, V3, V4, V5) -> Unit,
) { ) {
val observer: Observer<*> = { addDisposable(world.phantasmal.observable.cell.observeNow(c1, c2, c3, c4, observer))
operation(v1.value, v2.value, v3.value, v4.value, v5.value) }
}
addDisposables( protected fun <T1, T2, T3, T4, T5> observeNow(
v1.observe(observer), c1: Cell<T1>,
v2.observe(observer), c2: Cell<T2>,
v3.observe(observer), c3: Cell<T3>,
v4.observe(observer), c4: Cell<T4>,
v5.observe(observer), c5: Cell<T5>,
) observer: (T1, T2, T3, T4, T5) -> Unit,
operation(v1.value, v2.value, v3.value, v4.value, v5.value) ) {
addDisposable(world.phantasmal.observable.cell.observeNow(c1, c2, c3, c4, c5, observer))
} }
} }

View File

@ -249,11 +249,8 @@ private fun <T> bindChildrenTo(
list: Cell<List<T>>, list: Cell<List<T>>,
createChild: Node.(T, index: Int) -> Node, createChild: Node.(T, index: Int) -> Node,
childrenRemoved: () -> Unit, childrenRemoved: () -> Unit,
): Disposable = ): Disposable {
list.observe(callNow = true) { (items) -> inline fun createChildNodes(items: List<T>) {
parent.innerHTML = ""
childrenRemoved()
val frag = document.createDocumentFragment() val frag = document.createDocumentFragment()
items.forEachIndexed { i, item -> items.forEachIndexed { i, item ->
@ -263,14 +260,33 @@ private fun <T> bindChildrenTo(
parent.appendChild(frag) parent.appendChild(frag)
} }
parent.innerHTML = ""
createChildNodes(list.value)
return list.observeChange { (items) ->
parent.innerHTML = ""
childrenRemoved()
createChildNodes(items)
}
}
private fun <T> bindChildrenTo( private fun <T> bindChildrenTo(
parent: Element, parent: Element,
list: ListCell<T>, list: ListCell<T>,
createChild: Node.(T, index: Int) -> Node, createChild: Node.(T, index: Int) -> Node,
childrenRemoved: (index: Int, count: Int) -> Unit, childrenRemoved: (index: Int, count: Int) -> Unit,
after: (ListChangeEvent<T>) -> Unit, after: (ListChangeEvent<T>) -> Unit,
): Disposable = ): Disposable {
list.observeList(callNow = true) { event: ListChangeEvent<T> -> parent.innerHTML = ""
val initialFrag = document.createDocumentFragment()
list.value.forEachIndexed { i, value ->
initialFrag.appendChild(initialFrag.createChild(value, i))
}
parent.appendChild(initialFrag)
return list.observeListChange { event: ListChangeEvent<T> ->
for (change in event.changes) { for (change in event.changes) {
if (change is ListChange.Structural) { if (change is ListChange.Structural) {
if (change.allRemoved) { if (change.allRemoved) {
@ -299,3 +315,4 @@ private fun <T> bindChildrenTo(
after(event) after(event)
} }
}

View File

@ -54,7 +54,7 @@ open class Button(
className = "pw-button-center" className = "pw-button-center"
if (textCell != null) { if (textCell != null) {
observe(textCell) { observeNow(textCell) {
textContent = it textContent = it
hidden = it.isEmpty() hidden = it.isEmpty()
} }

View File

@ -23,7 +23,7 @@ class Checkbox(
type = "checkbox" type = "checkbox"
if (this@Checkbox.checked != null) { if (this@Checkbox.checked != null) {
observe(this@Checkbox.checked) { observeNow(this@Checkbox.checked) {
checked = it checked = it
} }
} }

View File

@ -42,8 +42,8 @@ class ComboBox<T : Any>(
id = labelId id = labelId
placeholderText?.let { placeholder = it } placeholderText?.let { placeholder = it }
hidden(!visible) hidden(!visible)
observe(enabled) { disabled = !it } observeNow(enabled) { disabled = !it }
observe(selected) { value = it?.let(itemToString) ?: "" } observeNow(selected) { value = it?.let(itemToString) ?: "" }
onmousedown = ::onInputMouseDown onmousedown = ::onInputMouseDown
onkeydown = ::onInputKeyDown onkeydown = ::onInputKeyDown

View File

@ -73,7 +73,7 @@ open class Dialog(
} }
init { init {
observe(visible) { observeNow(visible) {
if (it) { if (it) {
setPosition( setPosition(
(window.innerWidth - WIDTH) / 2, (window.innerWidth - WIDTH) / 2,

View File

@ -34,13 +34,13 @@ abstract class Input<T>(
id = labelId id = labelId
classList.add("pw-input-inner") classList.add("pw-input-inner")
observe(this@Input.enabled) { disabled = !it } observeNow(this@Input.enabled) { disabled = !it }
onchange = { callOnChange(this) } onchange = { callOnChange(this) }
interceptInputElement(this) interceptInputElement(this)
observe(this@Input.value) { observeNow(this@Input.value) {
setInputValue(this, it) setInputValue(this, it)
} }
} }

View File

@ -16,7 +16,7 @@ class LazyLoader(
div { div {
className = "pw-lazy-loader" className = "pw-lazy-loader"
observe(this@LazyLoader.visible) { v -> observeNow(this@LazyLoader.visible) { v ->
if (v && !initialized) { if (v && !initialized) {
initialized = true initialized = true
addChild(createWidget()) addChild(createWidget())

View File

@ -53,7 +53,7 @@ class Menu<T : Any>(
} }
} }
observe(this@Menu.visible) { observeNow(this@Menu.visible) {
if (it) { if (it) {
onDocumentMouseDownListener = onDocumentMouseDownListener =
document.disposableListener("mousedown", ::onDocumentMouseDown) document.disposableListener("mousedown", ::onDocumentMouseDown)
@ -66,13 +66,13 @@ class Menu<T : Any>(
} }
} }
observe(enabled) { observeNow(enabled) {
if (!it) { if (!it) {
clearHighlightItem() clearHighlightItem()
} }
} }
observe(items) { observeNow(items) {
clearHighlightItem() clearHighlightItem()
} }

View File

@ -37,7 +37,7 @@ class Select<T : Any>(
this@Select.className?.let { classList.add(it) } this@Select.className?.let { classList.add(it) }
// Default to a single space so the inner text part won't be hidden. // Default to a single space so the inner text part won't be hidden.
observe(selected) { buttonText.value = it?.let(itemToString) ?: " " } observeNow(selected) { buttonText.value = it?.let(itemToString) ?: " " }
addWidget(Button( addWidget(Button(
enabled = enabled, enabled = enabled,

View File

@ -28,7 +28,7 @@ class TabContainer<T : Tab>(
title = tab.title title = tab.title
textContent = tab.title textContent = tab.title
observe(ctrl.activeTab) { observeNow(ctrl.activeTab) {
if (it == tab) { if (it == tab) {
classList.add(ACTIVE_CLASS) classList.add(ACTIVE_CLASS)
} else { } else {
@ -55,7 +55,7 @@ class TabContainer<T : Tab>(
} }
init { init {
observe(selfOrAncestorVisible, ctrl::visibleChanged) observeNow(selfOrAncestorVisible, ctrl::visibleChanged)
} }
companion object { companion object {

View File

@ -4,6 +4,7 @@ import org.w3c.dom.*
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.observable.cell.trueCell import world.phantasmal.observable.cell.trueCell
import world.phantasmal.webui.LoadingStatus import world.phantasmal.webui.LoadingStatus
import world.phantasmal.webui.controllers.Column import world.phantasmal.webui.controllers.Column
@ -30,7 +31,7 @@ class Table<T>(
div { div {
className = "pw-table-notification" className = "pw-table-notification"
observe(loadingStatus) { status -> observeNow(loadingStatus) { status ->
when (status) { when (status) {
LoadingStatus.Uninitialized, LoadingStatus.Uninitialized,
LoadingStatus.InitialLoad, LoadingStatus.InitialLoad,
@ -167,8 +168,8 @@ class Table<T>(
style.width = "${column.width}px" style.width = "${column.width}px"
column.textAlign?.let { style.textAlign = it } column.textAlign?.let { style.textAlign = it }
disposer.add(column.footer.observe(callNow = true) { textContent = it.value ?: "" }) disposer.add(column.footer.observeNow { textContent = it ?: "" })
disposer.add(column.footerTooltip.observe(callNow = true) { title = it.value ?: "" }) disposer.add(column.footerTooltip.observeNow { title = it ?: "" })
} }
return Pair(cell, disposer) return Pair(cell, disposer)

View File

@ -37,13 +37,13 @@ class TextArea(
id = labelId id = labelId
className = "pw-text-area-inner" className = "pw-text-area-inner"
observe(this@TextArea.enabled) { disabled = !it } observeNow(this@TextArea.enabled) { disabled = !it }
if (onChange != null) { if (onChange != null) {
onchange = { onChange.invoke(value) } onchange = { onChange.invoke(value) }
} }
observe(this@TextArea.value) { value = it } observeNow(this@TextArea.value) { value = it }
this@TextArea.maxLength?.let { maxLength = it } this@TextArea.maxLength?.let { maxLength = it }
fontFamily?.let { style.fontFamily = it } fontFamily?.let { style.fontFamily = it }

View File

@ -12,7 +12,6 @@ import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.DisposableSupervisedScope import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.* import world.phantasmal.observable.cell.*
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.* import world.phantasmal.webui.dom.*
@ -36,12 +35,12 @@ abstract class Widget(
private val elementDelegate = lazy { private val elementDelegate = lazy {
val el = documentFragment().createElement() val el = documentFragment().createElement()
observe(visible) { visible -> observeNow(visible) { visible ->
el.hidden = !visible el.hidden = !visible
children.forEach { setAncestorVisible(it, visible && ancestorVisible.value) } children.forEach { setAncestorVisible(it, visible && ancestorVisible.value) }
} }
observe(enabled) { enabled -> observeNow(enabled) { enabled ->
if (enabled) { if (enabled) {
el.removeAttribute("disabled") el.removeAttribute("disabled")
el.classList.remove("pw-disabled") el.classList.remove("pw-disabled")
@ -51,7 +50,7 @@ abstract class Widget(
} }
} }
observe(tooltip) { tooltip -> observeNow(tooltip) { tooltip ->
if (tooltip == null) { if (tooltip == null) {
el.removeAttribute("title") el.removeAttribute("title")
} else { } else {
@ -111,16 +110,16 @@ abstract class Widget(
super.dispose() super.dispose()
} }
protected fun Node.text(observable: Observable<String>) { protected fun Node.text(cell: Cell<String>) {
observe(observable) { textContent = it } observeNow(cell) { textContent = it }
} }
protected fun HTMLElement.hidden(observable: Observable<Boolean>) { protected fun HTMLElement.hidden(cell: Cell<Boolean>) {
observe(observable) { hidden = it } observeNow(cell) { hidden = it }
} }
protected fun HTMLElement.toggleClass(className: String, observable: Observable<Boolean>) { protected fun HTMLElement.toggleClass(className: String, cell: Cell<Boolean>) {
observe(observable) { observeNow(cell) {
if (it) classList.add(className) if (it) classList.add(className)
else classList.remove(className) else classList.remove(className)
} }