Implemented most of the new observable algorithm.

This commit is contained in:
Daan Vanden Bosch 2022-05-10 14:00:43 +02:00
parent 0cea2d816d
commit 9aa963fd3b
116 changed files with 2517 additions and 1839 deletions

View File

@ -5,3 +5,7 @@ inline fun assert(value: () -> Boolean) {
}
expect inline fun assert(value: () -> Boolean, lazyMessage: () -> Any)
inline fun assertUnreachable(lazyMessage: () -> Any) {
assert({ true }, lazyMessage)
}

View File

@ -0,0 +1,107 @@
package world.phantasmal.core.disposable
private const val DISPOSABLE_PRINT_COUNT = 10
/**
* A global count is kept of all undisposed, tracked disposables. This count is used to find memory
* leaks.
*
* Tracking is not thread-safe.
*/
object DisposableTracking {
var globalDisposableTracker: DisposableTracker? = null
inline fun checkNoLeaks(block: () -> Unit) {
// Remember the old tracker to make this function reentrant.
val initialTracker = globalDisposableTracker
try {
val tracker = DisposableTracker()
globalDisposableTracker = tracker
block()
check(globalDisposableTracker === tracker) {
"Tracker was changed."
}
tracker.checkLeakCountZero()
} finally {
globalDisposableTracker = initialTracker
}
}
fun track(disposable: Disposable) {
globalDisposableTracker?.track(disposable)
}
fun disposed(disposable: Disposable) {
globalDisposableTracker?.disposed(disposable)
}
}
class DisposableTracker {
/**
* Mapping of tracked disposables to their liveness state. True means live, false means
* disposed.
*/
private var disposables: MutableMap<Disposable, Boolean> = mutableMapOf()
/** Keep count as optimization for check in [checkLeakCountZero]. */
private var liveCount = 0
fun track(disposable: Disposable) {
val live = disposables.put(disposable, true)
if (live == true) {
error("${getName(disposable)} was already tracked.")
} else if (live == false) {
disposables[disposable] = false
error("${getName(disposable)} was already tracked and then disposed.")
}
liveCount++
}
fun disposed(disposable: Disposable) {
val live = disposables.put(disposable, false)
if (live == null) {
disposables.remove(disposable)
error("${getName(disposable)} was never tracked.")
} else if (!live) {
error("${getName(disposable)} was already disposed.")
}
liveCount--
}
fun checkLeakCountZero() {
check(liveCount == 0) {
buildString {
append(liveCount)
append(" Disposables were leaked: ")
val leakedDisposables = disposables.entries
.asSequence()
.filter { (_, live) -> live }
.take(DISPOSABLE_PRINT_COUNT)
.map { (disposable, _) -> disposable }
.toList()
check(liveCount >= leakedDisposables.size)
leakedDisposables.joinTo(this, transform = ::getName)
if (liveCount > DISPOSABLE_PRINT_COUNT) {
append(",...")
} else {
append(".")
}
}
}
}
}
private fun getName(disposable: Disposable): String =
disposable::class.simpleName ?: "(anonymous class)"

View File

@ -1,10 +1,8 @@
package world.phantasmal.core.disposable
/**
* A global count is kept of all undisposed instances of this class.
* This count can be used to find memory leaks.
*
* Tracking is not thread-safe.
* Subclasses of this class are automatically tracked. Subclasses are required to call
* super.[dispose].
*/
abstract class TrackedDisposable : Disposable {
var disposed = false
@ -13,76 +11,15 @@ abstract class TrackedDisposable : Disposable {
init {
// Suppress this warning, because track simply adds this disposable to a set at this point.
@Suppress("LeakingThis")
track(this)
DisposableTracking.track(this)
}
override fun dispose() {
if (!disposed) {
disposed = true
untrack(this)
}
}
companion object {
private const val DISPOSABLE_PRINT_COUNT = 10
var disposables: MutableSet<Disposable> = mutableSetOf()
var trackPrecise = false
var disposableCount: Int = 0
private set
inline fun checkNoLeaks(trackPrecise: Boolean = false, block: () -> Unit) {
val initialCount = disposableCount
val initialTrackPrecise = this.trackPrecise
val initialDisposables = disposables
this.trackPrecise = trackPrecise
disposables = mutableSetOf()
try {
block()
checkLeakCountZero(disposableCount - initialCount)
} finally {
this.trackPrecise = initialTrackPrecise
disposables = initialDisposables
}
require(!disposed) {
"${this::class.simpleName ?: "(Anonymous class)"} already disposed."
}
fun track(disposable: Disposable) {
disposableCount++
if (trackPrecise) {
disposables.add(disposable)
}
}
fun untrack(disposable: Disposable) {
disposableCount--
if (trackPrecise) {
disposables.remove(disposable)
}
}
fun checkLeakCountZero(leakCount: Int) {
check(leakCount == 0) {
buildString {
append("$leakCount TrackedDisposables were leaked")
if (trackPrecise) {
append(": ")
disposables.take(DISPOSABLE_PRINT_COUNT).joinTo(this) {
it::class.simpleName ?: "Anonymous"
}
if (disposables.size > DISPOSABLE_PRINT_COUNT) {
append(",..")
}
}
append(".")
}
}
}
disposed = true
DisposableTracking.disposed(this)
}
}

View File

@ -17,7 +17,8 @@ package world.phantasmal.core.unsafe
*
* 3. The keys used do not require equals or hashCode to be called in JS.
* E.g. Int, String, objects which you consider equal if and only if they are the exact same
* instance.
* instance. Note that some objects that compile to primitives on JVM, such as Long, compile to
* an object in JS and thus will not behave the way you expect.
*/
expect class UnsafeMap<K, V>() {
fun get(key: K): V?

View File

@ -17,7 +17,8 @@ package world.phantasmal.core.unsafe
*
* 3. The values used do not require equals or hashCode to be called in JS.
* E.g. Int, String, objects which you consider equal if and only if they are the exact same
* instance.
* instance. Note that some objects that compile to primitives on JVM, such as Long, compile to
* an object in JS and thus will not behave the way you expect.
*/
expect class UnsafeSet<T> {
constructor()

View File

@ -5,7 +5,7 @@ import kotlin.test.*
class DisposerTests {
@Test
fun calling_add_or_addAll_increases_size_correctly() {
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
assertEquals(disposer.size, 0)
@ -29,7 +29,7 @@ class DisposerTests {
@Test
fun disposes_all_its_disposables_when_disposed() {
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
var disposablesDisposed = 0
@ -57,7 +57,7 @@ class DisposerTests {
@Test
fun disposeAll_disposes_all_disposables() {
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
var disposablesDisposed = 0
@ -80,7 +80,7 @@ class DisposerTests {
@Test
fun size_and_is_empty_should_correctly_reflect_the_contained_disposables() {
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
assertEquals(disposer.size, 0)
@ -102,7 +102,7 @@ class DisposerTests {
@Test
fun adding_disposables_after_being_disposed_throws() {
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
disposer.dispose()

View File

@ -1,35 +1,34 @@
package world.phantasmal.core.disposable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TrackedDisposableTests {
@Test
fun count_goes_up_when_created_and_down_when_disposed() {
val initialCount = TrackedDisposable.disposableCount
fun is_correctly_tracked() {
assertFails {
checkNoDisposableLeaks {
object : TrackedDisposable() {}
}
}
val disposable = object : TrackedDisposable() {}
assertEquals(initialCount + 1, TrackedDisposable.disposableCount)
disposable.dispose()
assertEquals(initialCount, TrackedDisposable.disposableCount)
checkNoDisposableLeaks {
val disposable = object : TrackedDisposable() {}
disposable.dispose()
}
}
@Test
fun double_dispose_does_not_increase_count() {
val initialCount = TrackedDisposable.disposableCount
fun double_dispose_throws() {
val disposable = object : TrackedDisposable() {}
for (i in 1..5) {
disposable.dispose()
assertFails {
disposable.dispose()
}
assertEquals(initialCount, TrackedDisposable.disposableCount)
}
@Test

View File

@ -1,5 +1,13 @@
package world.phantasmal.core
import kotlin.contracts.InvocationKind.AT_MOST_ONCE
import kotlin.contracts.contract
actual inline fun assert(value: () -> Boolean, lazyMessage: () -> Any) {
contract {
callsInPlace(value, AT_MOST_ONCE)
callsInPlace(lazyMessage, AT_MOST_ONCE)
}
// TODO: Figure out a sensible way to do dev assertions in JS.
}

View File

@ -2,9 +2,17 @@
package world.phantasmal.core
import kotlin.contracts.InvocationKind.AT_MOST_ONCE
import kotlin.contracts.contract
val ASSERTIONS_ENABLED: Boolean = {}.javaClass.desiredAssertionStatus()
actual inline fun assert(value: () -> Boolean, lazyMessage: () -> Any) {
contract {
callsInPlace(value, AT_MOST_ONCE)
callsInPlace(lazyMessage, AT_MOST_ONCE)
}
if (ASSERTIONS_ENABLED && !value()) {
throw AssertionError(lazyMessage())
}

View File

@ -1,6 +1,9 @@
package world.phantasmal.observable
abstract class AbstractDependency : Dependency {
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
abstract class AbstractDependency<T> : Dependency<T> {
protected val dependents: MutableList<Dependent> = mutableListOf()
override fun addDependent(dependent: Dependent) {
@ -10,4 +13,21 @@ abstract class AbstractDependency : Dependency {
override fun removeDependent(dependent: Dependent) {
dependents.remove(dependent)
}
protected fun emitDependencyInvalidated() {
for (dependent in dependents) {
dependent.dependencyInvalidated(this)
}
}
protected inline fun applyChange(block: () -> Unit) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
ChangeManager.changeDependency {
emitDependencyInvalidated()
block()
}
}
}

View File

@ -7,11 +7,12 @@ 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.
private val dependency: Observable<T>,
// We don't use ChangeObserver<T> because of type system limitations. It would break e.g.
// AbstractListCell.observeListChange.
private val callback: (E) -> Unit,
) : TrackedDisposable(), Dependent {
) : TrackedDisposable(), Dependent, LeafDependent {
init {
dependency.addDependent(this)
}
@ -21,13 +22,12 @@ class CallbackChangeObserver<T, E : ChangeEvent<T>>(
super.dispose()
}
override fun dependencyMightChange() {
// Do nothing.
override fun dependencyInvalidated(dependency: Dependency<*>) {
ChangeManager.invalidated(this)
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
callback(unsafeCast(event))
}
override fun pull() {
// See comment above callback property to understand why this is safe.
dependency.changeEvent?.let(unsafeCast<(ChangeEvent<T>) -> Unit>(callback))
}
}

View File

@ -3,14 +3,12 @@ package world.phantasmal.observable
import world.phantasmal.core.disposable.TrackedDisposable
/**
* Calls [callback] when one or more dependency in [dependencies] changes.
* Calls [callback] when one or more observable in [dependencies] changes.
*/
class CallbackObserver(
private vararg val dependencies: Dependency,
private vararg val dependencies: Observable<*>,
private val callback: () -> Unit,
) : TrackedDisposable(), Dependent {
private var changingDependencies = 0
private var dependenciesActuallyChanged = false
) : TrackedDisposable(), Dependent, LeafDependent {
init {
for (dependency in dependencies) {
@ -26,20 +24,21 @@ class CallbackObserver(
super.dispose()
}
override fun dependencyMightChange() {
changingDependencies++
override fun dependencyInvalidated(dependency: Dependency<*>) {
ChangeManager.invalidated(this)
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
dependenciesActuallyChanged = true
override fun pull() {
var changed = false
// We loop through all dependencies to ensure they're valid again.
for (dependency in dependencies) {
if (dependency.changeEvent != null) {
changed = true
}
}
changingDependencies--
if (changingDependencies == 0 && dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
if (changed) {
callback()
}
}

View File

@ -1,57 +1,56 @@
package world.phantasmal.observable
import kotlin.contracts.InvocationKind.EXACTLY_ONCE
import kotlin.contracts.contract
// TODO: Throw exception by default when triggering early recomputation during change set. Allow to
// to turn this check off, because partial early recomputation might be useful in rare cases.
// Dependencies will need to partially apply ListChangeEvents etc. and remember which part of
// the event they've already applied (i.e. an index into the changes list).
// TODO: Think about nested change sets. Initially don't allow nesting?
object ChangeManager {
private var currentChangeSet: ChangeSet? = null
private val invalidatedLeaves = HashSet<LeafDependent>()
/** Whether a dependency's value is changing at the moment. */
private var dependencyChanging = false
fun inChangeSet(block: () -> Unit) {
// TODO: Figure out change set bug and enable change sets again.
// val existingChangeSet = currentChangeSet
// val changeSet = existingChangeSet ?: ChangeSet().also {
// currentChangeSet = it
// }
//
// try {
block()
// } finally {
// if (existingChangeSet == null) {
// // Set to null so changed calls are turned into emitDependencyChanged calls
// // immediately instead of being deferred.
// currentChangeSet = null
// changeSet.complete()
// }
// }
// TODO: Implement inChangeSet correctly.
block()
}
fun changed(dependency: Dependency) {
val changeSet = currentChangeSet
fun invalidated(dependent: LeafDependent) {
invalidatedLeaves.add(dependent)
}
if (changeSet == null) {
dependency.emitDependencyChanged()
} else {
changeSet.changed(dependency)
inline fun changeDependency(block: () -> Unit) {
contract {
callsInPlace(block, EXACTLY_ONCE)
}
dependencyStartedChanging()
try {
block()
} finally {
dependencyFinishedChanging()
}
}
}
private class ChangeSet {
private var completing = false
private val changedDependencies: MutableList<Dependency> = mutableListOf()
fun dependencyStartedChanging() {
check(!dependencyChanging) { "An observable is already changing." }
fun changed(dependency: Dependency) {
check(!completing)
changedDependencies.add(dependency)
dependencyChanging = true
}
fun complete() {
fun dependencyFinishedChanging() {
try {
completing = true
for (dependency in changedDependencies) {
dependency.emitDependencyChanged()
for (dependent in invalidatedLeaves) {
dependent.pull()
}
} finally {
completing = false
dependencyChanging = false
invalidatedLeaves.clear()
}
}
}

View File

@ -1,6 +1,9 @@
package world.phantasmal.observable
interface Dependency {
interface Dependency<out T> {
// TODO: Docs.
val changeEvent: ChangeEvent<T>?
/**
* This method is not meant to be called from typical application code. Usually you'll want to
* use [Observable.observeChange].
@ -11,9 +14,4 @@ interface Dependency {
* This method is not meant to be called from typical application code.
*/
fun removeDependent(dependent: Dependent)
/**
* This method is not meant to be called from typical application code.
*/
fun emitDependencyChanged()
}

View File

@ -2,6 +2,7 @@ package world.phantasmal.observable
interface Dependent {
/**
* TODO: Fix documentation.
* This method is not meant to be called from typical application code.
*
* Called whenever a dependency of this dependent might change. Sometimes a dependency doesn't
@ -9,16 +10,14 @@ interface Dependent {
* after calling this method.
*
* E.g. C depends on B and B depends on A. A is about to change, so it calls
* [dependencyMightChange] on B. At this point B doesn't know whether it will actually change
* [dependencyInvalidated] on B. At this point B doesn't know whether it will actually change
* since the new value of A doesn't necessarily result in a new value for B (e.g. B = A % 2 and
* A changes from 0 to 2). So B then calls [dependencyMightChange] on C.
* A changes from 0 to 2). So B then calls [dependencyInvalidated] on C.
*/
fun dependencyMightChange()
/**
* This method is not meant to be called from typical application code.
*
* Always call [dependencyMightChange] before calling this method.
*/
fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?)
fun dependencyInvalidated(dependency: Dependency<*>)
}
interface LeafDependent {
// TODO: Sensible name for `pull`.
fun pull()
}

View File

@ -2,7 +2,7 @@ package world.phantasmal.observable
import world.phantasmal.core.disposable.Disposable
interface Observable<out T> : Dependency {
interface Observable<out T> : Dependency<T> {
/**
* [observer] will be called whenever this observable changes.
*/

View File

@ -2,31 +2,18 @@ package world.phantasmal.observable
import world.phantasmal.core.disposable.Disposable
class SimpleEmitter<T> : AbstractDependency(), Emitter<T> {
private var event: ChangeEvent<T>? = null
// TODO: Should multiple events be emitted somehow during a change set? At the moment no application
// code seems to care.
class SimpleEmitter<T> : AbstractDependency<T>(), Emitter<T> {
override var changeEvent: ChangeEvent<T>? = null
private set
override fun emit(event: ChangeEvent<T>) {
for (dependent in dependents) {
dependent.dependencyMightChange()
applyChange {
this.changeEvent = event
}
this.event = event
ChangeManager.changed(this)
}
override fun observeChange(observer: ChangeObserver<T>): Disposable =
CallbackChangeObserver(this, observer)
override fun emitDependencyChanged() {
if (event != null) {
try {
for (dependent in dependents) {
dependent.dependencyChanged(this, event)
}
} finally {
event = null
}
}
}
}

View File

@ -3,34 +3,11 @@ package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.observable.AbstractDependency
import world.phantasmal.observable.CallbackChangeObserver
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.ChangeObserver
abstract class AbstractCell<T> : AbstractDependency(), Cell<T> {
private var mightChangeEmitted = false
abstract class AbstractCell<T> : AbstractDependency<T>(), Cell<T> {
override fun observeChange(observer: ChangeObserver<T>): Disposable =
CallbackChangeObserver(this, observer)
protected fun emitMightChange() {
if (!mightChangeEmitted) {
mightChangeEmitted = true
for (dependent in dependents) {
dependent.dependencyMightChange()
}
}
}
protected fun emitDependencyChangedEvent(event: ChangeEvent<*>?) {
if (mightChangeEmitted) {
mightChangeEmitted = false
for (dependent in dependents) {
dependent.dependencyChanged(this, event)
}
}
}
override fun toString(): String = "${this::class.simpleName}[$value]"
override fun toString(): String = cellToString(this)
}

View File

@ -1,45 +1,31 @@
package world.phantasmal.observable.cell
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
abstract class AbstractDependentCell<T> : AbstractCell<T>(), Dependent {
private var changingDependencies = 0
private var dependenciesActuallyChanged = false
override fun dependencyMightChange() {
changingDependencies++
emitMightChange()
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
dependenciesActuallyChanged = true
private var _value: T? = null
final override val value: T
get() {
computeValueAndEvent()
// We cast instead of asserting _value is non-null because T might actually be a
// nullable type.
return unsafeCast(_value)
}
changingDependencies--
if (changingDependencies == 0) {
if (dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
dependenciesFinishedChanging()
} else {
emitDependencyChangedEvent(null)
}
final override var changeEvent: ChangeEvent<T>? = null
get() {
computeValueAndEvent()
return field
}
}
private set
override fun emitDependencyChanged() {
// Nothing to do because dependent cells emit dependencyChanged immediately. They don't
// defer this operation because they only change when there is no change set or the current
// change set is being completed.
}
protected abstract fun computeValueAndEvent()
/**
* Called after a wave of dependencyMightChange notifications followed by an equal amount of
* dependencyChanged notifications of which at least one signified an actual change.
*/
protected abstract fun dependenciesFinishedChanging()
protected fun setValueAndEvent(value: T, changeEvent: ChangeEvent<T>?) {
_value = value
this.changeEvent = changeEvent
}
}

View File

@ -42,9 +42,10 @@ fun <T> mutableCell(getter: () -> T, setter: (T) -> Unit): MutableCell<T> =
fun <T> Cell<T>.observeNow(
observer: (T) -> Unit,
): Disposable {
val disposable = observeChange { observer(it.value) }
// Call observer after observeChange to avoid double recomputation in most observables.
observer(value)
return observeChange { observer(it.value) }
return disposable
}
fun <T1, T2> observeNow(
@ -52,9 +53,10 @@ fun <T1, T2> observeNow(
c2: Cell<T2>,
observer: (T1, T2) -> Unit,
): Disposable {
val disposable = CallbackObserver(c1, c2) { observer(c1.value, c2.value) }
// Call observer after observeChange to avoid double recomputation in most observables.
observer(c1.value, c2.value)
return CallbackObserver(c1, c2) { observer(c1.value, c2.value) }
return disposable
}
fun <T1, T2, T3> observeNow(
@ -63,9 +65,10 @@ fun <T1, T2, T3> observeNow(
c3: Cell<T3>,
observer: (T1, T2, T3) -> Unit,
): Disposable {
val disposable = CallbackObserver(c1, c2, c3) { observer(c1.value, c2.value, c3.value) }
// Call observer after observeChange to avoid double recomputation in most observables.
observer(c1.value, c2.value, c3.value)
return CallbackObserver(c1, c2, c3) { observer(c1.value, c2.value, c3.value) }
return disposable
}
fun <T1, T2, T3, T4> observeNow(
@ -75,9 +78,11 @@ fun <T1, T2, T3, T4> observeNow(
c4: Cell<T4>,
observer: (T1, T2, T3, T4) -> Unit,
): Disposable {
val disposable =
CallbackObserver(c1, c2, c3, c4) { observer(c1.value, c2.value, c3.value, c4.value) }
// Call observer after observeChange to avoid double recomputation in most observables.
observer(c1.value, c2.value, c3.value, c4.value)
return CallbackObserver(c1, c2, c3, c4) { observer(c1.value, c2.value, c3.value, c4.value) }
return disposable
}
fun <T1, T2, T3, T4, T5> observeNow(
@ -88,11 +93,12 @@ fun <T1, T2, T3, T4, T5> observeNow(
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) {
val disposable = CallbackObserver(c1, c2, c3, c4, c5) {
observer(c1.value, c2.value, c3.value, c4.value, c5.value)
}
// Call observer after observeChange to avoid double recomputation in most observables.
observer(c1.value, c2.value, c3.value, c4.value, c5.value)
return disposable
}
/**
@ -234,3 +240,9 @@ fun Cell<String>.isBlank(): Cell<Boolean> =
fun Cell<String>.isNotBlank(): Cell<Boolean> =
map { it.isNotBlank() }
fun cellToString(cell: Cell<*>): String {
val className = cell::class.simpleName
val value = cell.value
return "$className{$value}"
}

View File

@ -1,27 +1,24 @@
package world.phantasmal.observable.cell
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.ChangeManager
class DelegatingCell<T>(
private val getter: () -> T,
private val setter: (T) -> Unit,
) : AbstractCell<T>(), MutableCell<T> {
override var value: T
get() = getter()
override var value: T = getter()
set(value) {
val oldValue = getter()
setter(value)
val newValue = getter()
if (value != oldValue) {
emitMightChange()
setter(value)
ChangeManager.changed(this)
if (newValue != field) {
applyChange {
field = newValue
changeEvent = ChangeEvent(newValue)
}
}
}
override fun emitDependencyChanged() {
emitDependencyChangedEvent(ChangeEvent(value))
}
override var changeEvent: ChangeEvent<T>? = null
private set
}

View File

@ -1,38 +1,34 @@
package world.phantasmal.observable.cell
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.Observable
/**
* Cell of which the value depends on 0 or more dependencies.
*/
class DependentCell<T>(
private vararg val dependencies: Dependency,
private vararg val dependencies: Observable<*>,
private val compute: () -> T,
) : AbstractDependentCell<T>() {
private var _value: T? = null
override val value: T
get() {
// Recompute value every time when we have no dependents. At this point we're not yet a
// dependent of our own dependencies, and thus we won't automatically recompute our
// value when they change.
if (dependents.isEmpty()) {
_value = compute()
}
private var valid = false
return unsafeCast(_value)
override fun computeValueAndEvent() {
// Recompute value every time when we have no dependents. At that point we're not yet a
// dependent of our own dependencies, and thus we won't automatically recompute our value
// when they change.
if (!valid) {
val newValue = compute()
setValueAndEvent(newValue, ChangeEvent(newValue))
valid = dependents.isNotEmpty()
}
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
// Start actually depending on or dependencies when we get our first dependent.
// Make sure value is up-to-date here, because from now on `compute` will only be called
// when our dependencies change.
_value = compute()
// Start actually depending on our dependencies when we get our first dependent.
for (dependency in dependencies) {
dependency.addDependent(this)
}
@ -45,6 +41,10 @@ class DependentCell<T>(
super.removeDependent(dependent)
if (dependents.isEmpty()) {
// As long as we don't have any dependents we're permanently "invalid", i.e. we always
// recompute our value.
valid = false
// Stop actually depending on our dependencies when we no longer have any dependents.
for (dependency in dependencies) {
dependency.removeDependent(this)
@ -52,9 +52,8 @@ class DependentCell<T>(
}
}
override fun dependenciesFinishedChanging() {
val newValue = compute()
_value = newValue
emitDependencyChangedEvent(ChangeEvent(newValue))
override fun dependencyInvalidated(dependency: Dependency<*>) {
valid = false
emitDependencyInvalidated()
}
}

View File

@ -1,56 +1,80 @@
package world.phantasmal.observable.cell
import world.phantasmal.core.unsafe.unsafeAssertNotNull
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.Observable
/**
* Similar to [DependentCell], except that this cell's [compute] returns a cell.
*/
// TODO: Shares 99% of its code with FlatteningDependentListCell, should use common super class.
class FlatteningDependentCell<T>(
private vararg val dependencies: Dependency,
private vararg val dependencies: Observable<*>,
private val compute: () -> Cell<T>,
) : AbstractDependentCell<T>() {
private var computedCell: Cell<T>? = null
private var computedInDeps = false
private var shouldRecompute = false
private var shouldRecomputeCell = true
private var valid = false
private var _value: T? = null
override val value: T
get() {
if (dependents.isEmpty()) {
_value = compute().value
override fun computeValueAndEvent() {
if (!valid) {
val hasDependents = dependents.isNotEmpty()
val computedCell: Cell<T>
if (shouldRecomputeCell) {
this.computedCell?.removeDependent(this)
computedCell = compute()
if (hasDependents) {
// Only hold onto and depend on the computed cell if we have dependents
// ourselves.
computedCell.addDependent(this)
this.computedCell = computedCell
computedInDeps = dependencies.any { it === computedCell }
shouldRecomputeCell = false
} else {
// Set field to null to allow the cell to be garbage collected.
this.computedCell = null
}
} else {
computedCell = unsafeAssertNotNull(this.computedCell)
}
return unsafeCast(_value)
val newValue = computedCell.value
setValueAndEvent(newValue, ChangeEvent(newValue))
// We stay invalid if we have no dependents to ensure our value is always recomputed.
valid = hasDependents
}
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
super.addDependent(dependent)
if (dependents.size == 1) {
for (dependency in dependencies) {
dependency.addDependent(this)
}
computedCell = compute().also { computedCell ->
computedCell.addDependent(this)
computedInDeps = dependencies.any { it === computedCell }
_value = computedCell.value
}
// Called to ensure that we depend on the computed cell. This could be optimized by
// avoiding the value and changeEvent calculation.
computeValueAndEvent()
}
super.addDependent(dependent)
}
override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent)
if (dependents.isEmpty()) {
valid = false
computedCell?.removeDependent(this)
// Set field to null to allow the cell to be garbage collected.
computedCell = null
computedInDeps = false
shouldRecomputeCell = true
for (dependency in dependencies) {
dependency.removeDependent(this)
@ -58,28 +82,19 @@ class FlatteningDependentCell<T>(
}
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if ((dependency !== computedCell || computedInDeps) && event != null) {
shouldRecompute = true
override fun dependencyInvalidated(dependency: Dependency<*>) {
valid = false
// We should recompute the computed cell when any dependency except the computed cell is
// invalidated. When the computed cell is in our dependency array (i.e. the computed cell
// itself takes part in determining what the computed cell is) we should also recompute.
if (dependency !== computedCell || computedInDeps) {
// We're not allowed to change the dependency graph at this point, so we just set this
// field to true and remove ourselves as dependency from the computed cell right before
// we recompute it.
shouldRecomputeCell = true
}
super.dependencyChanged(dependency, event)
}
override fun dependenciesFinishedChanging() {
if (shouldRecompute) {
computedCell?.removeDependent(this)
computedCell = compute().also { computedCell ->
computedCell.addDependent(this)
computedInDeps = dependencies.any { it === computedCell }
}
shouldRecompute = false
}
val newValue = unsafeAssertNotNull(computedCell).value
_value = newValue
emitDependencyChangedEvent(ChangeEvent(newValue))
emitDependencyInvalidated()
}
}

View File

@ -2,11 +2,14 @@ package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.nopDisposable
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
class ImmutableCell<T>(override val value: T) : Dependency, Cell<T> {
class ImmutableCell<T>(override val value: T) : Dependency<T>, Cell<T> {
override val changeEvent: ChangeEvent<T>? get() = null
override fun addDependent(dependent: Dependent) {
// We don't remember our dependents because we never need to notify them of changes.
}
@ -17,7 +20,5 @@ class ImmutableCell<T>(override val value: T) : Dependency, Cell<T> {
override fun observeChange(observer: ChangeObserver<T>): Disposable = nopDisposable()
override fun emitDependencyChanged() {
error("ImmutableCell can't change.")
}
override fun toString(): String = cellToString(this)
}

View File

@ -1,21 +1,18 @@
package world.phantasmal.observable.cell
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.ChangeManager
class SimpleCell<T>(value: T) : AbstractCell<T>(), MutableCell<T> {
override var value: T = value
set(value) {
if (value != field) {
emitMightChange()
field = value
ChangeManager.changed(this)
applyChange {
field = value
changeEvent = ChangeEvent(value)
}
}
}
override fun emitDependencyChanged() {
emitDependencyChangedEvent(ChangeEvent(value))
}
override var changeEvent: ChangeEvent<T>? = null
private set
}

View File

@ -1,83 +0,0 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.unsafe.unsafeAssertNotNull
import world.phantasmal.observable.CallbackChangeObserver
import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.cell.AbstractDependentCell
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
abstract class AbstractDependentListCell<E> :
AbstractDependentCell<List<E>>(),
ListCell<E>,
Dependent {
protected abstract val elements: List<E>
override val value: List<E>
get() {
if (dependents.isEmpty()) {
computeElements()
}
return elements
}
private var _size: Cell<Int>? = null
final override val size: Cell<Int>
get() {
if (_size == null) {
_size = DependentCell(this) { value.size }
}
return unsafeAssertNotNull(_size)
}
private var _empty: Cell<Boolean>? = null
final override val empty: Cell<Boolean>
get() {
if (_empty == null) {
_empty = DependentCell(this) { value.isEmpty() }
}
return unsafeAssertNotNull(_empty)
}
private var _notEmpty: Cell<Boolean>? = null
final override val notEmpty: Cell<Boolean>
get() {
if (_notEmpty == null) {
_notEmpty = DependentCell(this) { value.isNotEmpty() }
}
return unsafeAssertNotNull(_notEmpty)
}
final override fun observeChange(observer: ChangeObserver<List<E>>): Disposable =
observeListChange(observer)
override fun observeListChange(observer: ListChangeObserver<E>): Disposable =
CallbackChangeObserver(this, observer)
final override fun dependenciesFinishedChanging() {
val oldElements = value
computeElements()
emitDependencyChangedEvent(
ListChangeEvent(
elements,
listOf(ListChange(
index = 0,
prevSize = oldElements.size,
removed = oldElements,
inserted = elements,
)),
)
)
}
protected abstract fun computeElements()
}

View File

@ -0,0 +1,34 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.unsafe.unsafeAssertNotNull
abstract class AbstractElementsWrappingListCell<E> : AbstractListCell<E>() {
/**
* When [value] is accessed and this property is null, a new wrapper is created that points to
* [elements]. Before changes to [elements] are made, if there's a wrapper, the current
* wrapper's backing list is set to a copy of [elements] and this property is set to null. This
* way, accessing [value] acts like accessing a snapshot without making an actual copy
* everytime. This is necessary because the contract is that a cell's new value is always != to
* its old value whenever a change event was emitted (TODO: is this still the contract?).
*/
// TODO: Optimize this by using a weak reference to avoid copying when nothing references the
// wrapper.
// TODO: Just remove this because it's a huge headache? Does it matter that events are
// immutable?
private var _elementsWrapper: DelegatingList<E>? = null
protected val elementsWrapper: DelegatingList<E>
get() {
if (_elementsWrapper == null) {
_elementsWrapper = DelegatingList(elements)
}
return unsafeAssertNotNull(_elementsWrapper)
}
protected abstract val elements: List<E>
protected fun copyAndResetWrapper() {
_elementsWrapper?.backingList = elements.toList()
_elementsWrapper = null
}
}

View File

@ -1,42 +1,156 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
abstract class AbstractFilteredListCell<E>(
protected val list: ListCell<E>,
) : AbstractListCell<E>(), Dependent {
/** Keeps track of number of changing dependencies during a change wave. */
private var changingDependencies = 0
) : AbstractElementsWrappingListCell<E>(), Dependent {
/** Set during a change wave when [list] changes. */
private var listChangeEvent: ListChangeEvent<E>? = null
private var listInvalidated = false
/** Set during a change wave when [predicateDependency] changes. */
private var predicateChanged = false
private var predicateInvalidated = false
override val elements = mutableListOf<E>()
private var valid = false
protected abstract val predicateDependency: Dependency
final override val elements = mutableListOf<E>()
override val value: List<E>
protected abstract val predicateDependency: Dependency<*>
final override val value: List<E>
get() {
if (dependents.isEmpty()) {
recompute()
}
computeValueAndEvent()
return elementsWrapper
}
override fun addDependent(dependent: Dependent) {
val wasEmpty = dependents.isEmpty()
final override var changeEvent: ListChangeEvent<E>? = null
get() {
computeValueAndEvent()
return field
}
private set
private fun computeValueAndEvent() {
if (!valid) {
val hasDependents = dependents.isNotEmpty()
if (predicateInvalidated || !hasDependents) {
// Simply assume the entire list changes and recompute.
val removed = elementsWrapper
ignoreOtherChanges()
recompute()
changeEvent = ListChangeEvent(
elementsWrapper,
listOf(ListChange(0, removed.size, removed, elementsWrapper)),
)
} else {
// TODO: Conditionally copyAndResetWrapper?
copyAndResetWrapper()
val filteredChanges = mutableListOf<ListChange<E>>()
if (listInvalidated) {
list.changeEvent?.let { listChangeEvent ->
for (change in listChangeEvent.changes) {
val prevSize = elements.size
// Map the incoming change index to an index into our own elements list.
// TODO: Avoid this loop by storing the index where an element "would"
// be if it passed the predicate.
var eventIndex = prevSize
for (index in change.index..maxDepIndex()) {
val i = mapIndex(index)
if (i != -1) {
eventIndex = i
break
}
}
// Process removals.
val removed = mutableListOf<E>()
for (element in change.removed) {
val index = removeIndexMapping(change.index)
if (index != -1) {
elements.removeAt(eventIndex)
removed.add(element)
}
}
// Process insertions.
val inserted = mutableListOf<E>()
var insertionIndex = eventIndex
for ((i, element) in change.inserted.withIndex()) {
if (applyPredicate(element)) {
insertIndexMapping(change.index + i, insertionIndex, element)
elements.add(insertionIndex, element)
inserted.add(element)
insertionIndex++
} else {
insertIndexMapping(change.index + i, -1, element)
}
}
// Shift mapped indices by a certain amount. This amount can be
// positive, negative or zero.
val diff = inserted.size - removed.size
if (diff != 0) {
// Indices before the change index stay the same. Newly inserted
// indices are already correct. So we only need to shift everything
// after the new indices.
val startIndex = change.index + change.inserted.size
for (index in startIndex..maxDepIndex()) {
shiftIndexMapping(index, diff)
}
}
// Add a list change if something actually changed.
if (removed.isNotEmpty() || inserted.isNotEmpty()) {
filteredChanges.add(
ListChange(
eventIndex,
prevSize,
removed,
inserted,
)
)
}
}
}
}
processOtherChanges(filteredChanges)
changeEvent =
if (filteredChanges.isEmpty()) {
null
} else {
ListChangeEvent(elementsWrapper, filteredChanges)
}
}
// Reset for next change wave.
listInvalidated = false
predicateInvalidated = false
// We stay invalid if we have no dependents to ensure our value is always recomputed.
valid = hasDependents
}
resetChangeWaveData()
}
override fun addDependent(dependent: Dependent) {
super.addDependent(dependent)
if (wasEmpty) {
if (dependents.size == 1) {
list.addDependent(this)
predicateDependency.addDependent(this)
recompute()
@ -47,150 +161,33 @@ abstract class AbstractFilteredListCell<E>(
super.removeDependent(dependent)
if (dependents.isEmpty()) {
valid = false
predicateDependency.removeDependent(this)
list.removeDependent(this)
}
}
override fun dependencyMightChange() {
changingDependencies++
emitMightChange()
}
override fun dependencyInvalidated(dependency: Dependency<*>) {
valid = false
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (dependency === list) {
listChangeEvent = unsafeCast(event)
listInvalidated = true
} else if (dependency === predicateDependency) {
predicateChanged = event != null
} else if (event != null) {
otherDependencyChanged(dependency)
predicateInvalidated = true
} else {
otherDependencyInvalidated(dependency)
}
changingDependencies--
if (changingDependencies == 0) {
if (predicateChanged) {
// Simply assume the entire list changes and recompute.
val removed = elementsWrapper
ignoreOtherChanges()
recompute()
emitDependencyChangedEvent(
ListChangeEvent(
elementsWrapper,
listOf(ListChange(0, removed.size, removed, elementsWrapper)),
)
)
} else {
// TODO: Conditionally copyAndResetWrapper?
copyAndResetWrapper()
val filteredChanges = mutableListOf<ListChange<E>>()
val listChangeEvent = this.listChangeEvent
if (listChangeEvent != null) {
for (change in listChangeEvent.changes) {
val prevSize = elements.size
// Map the incoming change index to an index into our own elements list.
// TODO: Avoid this loop by storing the index where an element "would" be
// if it passed the predicate.
var eventIndex = prevSize
for (index in change.index..maxDepIndex()) {
val i = mapIndex(index)
if (i != -1) {
eventIndex = i
break
}
}
// Process removals.
val removed = mutableListOf<E>()
for (element in change.removed) {
val index = removeIndexMapping(change.index)
if (index != -1) {
elements.removeAt(eventIndex)
removed.add(element)
}
}
// Process insertions.
val inserted = mutableListOf<E>()
var insertionIndex = eventIndex
for ((i, element) in change.inserted.withIndex()) {
if (applyPredicate(element)) {
insertIndexMapping(change.index + i, insertionIndex, element)
elements.add(insertionIndex, element)
inserted.add(element)
insertionIndex++
} else {
insertIndexMapping(change.index + i, -1, element)
}
}
// Shift mapped indices by a certain amount. This amount can be positive,
// negative or zero.
val diff = inserted.size - removed.size
if (diff != 0) {
// Indices before the change index stay the same. Newly inserted indices
// are already correct. So we only need to shift everything after the
// new indices.
for (index in (change.index + change.inserted.size)..maxDepIndex()) {
shiftIndexMapping(index, diff)
}
}
// Add a list change if something actually changed.
if (removed.isNotEmpty() || inserted.isNotEmpty()) {
filteredChanges.add(
ListChange(
eventIndex,
prevSize,
removed,
inserted,
)
)
}
}
}
processOtherChanges(filteredChanges)
if (filteredChanges.isEmpty()) {
emitDependencyChangedEvent(null)
} else {
emitDependencyChangedEvent(
ListChangeEvent(elementsWrapper, filteredChanges)
)
}
}
// Reset for next change wave.
listChangeEvent = null
predicateChanged = false
resetChangeWaveData()
}
emitDependencyInvalidated()
}
/** Called when a dependency that's neither [list] nor [predicateDependency] has changed. */
protected abstract fun otherDependencyChanged(dependency: Dependency)
protected abstract fun otherDependencyInvalidated(dependency: Dependency<*>)
protected abstract fun ignoreOtherChanges()
protected abstract fun processOtherChanges(filteredChanges: MutableList<ListChange<E>>)
override fun emitDependencyChanged() {
// Nothing to do because AbstractFilteredListCell emits dependencyChanged immediately. We
// don't defer this operation because AbstractFilteredListCell only changes when there is no
// change set or the current change set is being completed.
}
protected abstract fun applyPredicate(element: E): Boolean
protected abstract fun maxDepIndex(): Int

View File

@ -7,37 +7,38 @@ import world.phantasmal.observable.ChangeObserver
import world.phantasmal.observable.cell.AbstractCell
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.not
abstract class AbstractListCell<E> : AbstractCell<List<E>>(), ListCell<E> {
/**
* When [value] is accessed and this property is null, a new wrapper is created that points to
* [elements]. Before changes to [elements] are made, if there's a wrapper, the current
* wrapper's backing list is set to a copy of [elements] and this property is set to null. This
* way, accessing [value] acts like accessing a snapshot without making an actual copy
* everytime. This is necessary because the contract is that a cell's new value is always != to
* its old value whenever a change event was emitted.
*/
// TODO: Optimize this by using a weak reference to avoid copying when nothing references the
// wrapper.
private var _elementsWrapper: DelegatingList<E>? = null
protected val elementsWrapper: DelegatingList<E>
private var _size: Cell<Int>? = null
final override val size: Cell<Int>
get() {
if (_elementsWrapper == null) {
_elementsWrapper = DelegatingList(elements)
if (_size == null) {
_size = DependentCell(this) { value.size }
}
return unsafeAssertNotNull(_elementsWrapper)
return unsafeAssertNotNull(_size)
}
protected abstract val elements: List<E>
private var _empty: Cell<Boolean>? = null
final override val empty: Cell<Boolean>
get() {
if (_empty == null) {
_empty = DependentCell(this) { value.isEmpty() }
}
@Suppress("LeakingThis")
final override val size: Cell<Int> = DependentCell(this) { value.size }
return unsafeAssertNotNull(_empty)
}
final override val empty: Cell<Boolean> = DependentCell(size) { size.value == 0 }
private var _notEmpty: Cell<Boolean>? = null
final override val notEmpty: Cell<Boolean>
get() {
if (_notEmpty == null) {
_notEmpty = DependentCell(this) { value.isNotEmpty() }
}
final override val notEmpty: Cell<Boolean> = !empty
return unsafeAssertNotNull(_notEmpty)
}
final override fun observeChange(observer: ChangeObserver<List<E>>): Disposable =
observeListChange(observer)
@ -45,8 +46,5 @@ abstract class AbstractListCell<E> : AbstractCell<List<E>>(), ListCell<E> {
override fun observeListChange(observer: ListChangeObserver<E>): Disposable =
CallbackChangeObserver(this, observer)
protected fun copyAndResetWrapper() {
_elementsWrapper?.backingList = elements.toList()
_elementsWrapper = null
}
override fun toString(): String = listCellToString(this)
}

View File

@ -1,23 +1,53 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.Observable
/**
* ListCell of which the value depends on 0 or more other cells.
* ListCell of which the value depends on 0 or more other observables.
*/
class DependentListCell<E>(
private vararg val dependencies: Cell<*>,
private vararg val dependencies: Observable<*>,
private val computeElements: () -> List<E>,
) : AbstractDependentListCell<E>() {
) : AbstractListCell<E>(), Dependent {
override var elements: List<E> = emptyList()
private var valid = false
private var _value: List<E> = emptyList()
override val value: List<E>
get() {
computeValueAndEvent()
return _value
}
override var changeEvent: ListChangeEvent<E>? = null
get() {
computeValueAndEvent()
return field
}
private set
private fun computeValueAndEvent() {
if (!valid) {
val oldElements = _value
val newElements = computeElements()
_value = newElements
changeEvent = ListChangeEvent(
newElements,
listOf(ListChange(
index = 0,
prevSize = oldElements.size,
removed = oldElements,
inserted = newElements,
)),
)
valid = dependents.isNotEmpty()
}
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
computeElements()
for (dependency in dependencies) {
dependency.addDependent(this)
}
@ -30,13 +60,16 @@ class DependentListCell<E>(
super.removeDependent(dependent)
if (dependents.isEmpty()) {
valid = false
for (dependency in dependencies) {
dependency.removeDependent(this)
}
}
}
override fun computeElements() {
elements = computeElements.invoke()
override fun dependencyInvalidated(dependency: Dependency<*>) {
valid = false
emitDependencyInvalidated()
}
}

View File

@ -1,6 +1,7 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.assert
import world.phantasmal.core.assertUnreachable
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
@ -20,7 +21,7 @@ class FilteredListCell<E>(
private val changedPredicateResults = mutableListOf<Mapping>()
override val predicateDependency: Dependency
override val predicateDependency: Dependency<*>
get() = predicate
override fun removeDependent(dependent: Dependent) {
@ -33,8 +34,11 @@ class FilteredListCell<E>(
}
}
override fun otherDependencyChanged(dependency: Dependency) {
assert { dependency is FilteredListCell<*>.Mapping }
override fun otherDependencyInvalidated(dependency: Dependency<*>) {
assert(
{ dependency is FilteredListCell<*>.Mapping },
{ "Expected $dependency to be a mapping." },
)
changedPredicateResults.add(unsafeCast(dependency))
}
@ -105,7 +109,8 @@ class FilteredListCell<E>(
}
// Can still contain changed mappings at this point if e.g. an element was removed after its
// predicate result changed.
// predicate result changed or a predicate result emitted multiple invalidation
// notifications.
changedPredicateResults.clear()
}
@ -182,15 +187,17 @@ class FilteredListCell<E>(
* pass the predicate.
*/
var index: Int,
) : Dependent, Dependency {
override fun dependencyMightChange() {
this@FilteredListCell.dependencyMightChange()
}
) : Dependent, Dependency<Boolean> {
override val changeEvent: ChangeEvent<Boolean>?
get() {
assertUnreachable { "Change event is never computed." }
return null
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
override fun dependencyInvalidated(dependency: Dependency<*>) {
assert { dependency === predicateResult }
this@FilteredListCell.dependencyChanged(this, event)
this@FilteredListCell.dependencyInvalidated(this)
}
override fun addDependent(dependent: Dependent) {
@ -204,9 +211,5 @@ class FilteredListCell<E>(
predicateResult.removeDependent(this)
}
override fun emitDependencyChanged() {
// Nothing to do.
}
}
}

View File

@ -1,48 +1,103 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.unsafe.unsafeAssertNotNull
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import world.phantasmal.observable.Observable
/**
* Similar to [DependentListCell], except that this cell's [computeElements] returns a [ListCell].
*/
// TODO: Shares 99% of its code with FlatteningDependentCell, should use common super class.
class FlatteningDependentListCell<E>(
private vararg val dependencies: Dependency,
private vararg val dependencies: Observable<*>,
private val computeElements: () -> ListCell<E>,
) : AbstractDependentListCell<E>() {
) : AbstractListCell<E>(), Dependent {
private var computedCell: ListCell<E>? = null
private var computedInDeps = false
private var shouldRecompute = false
private var shouldRecomputeCell = true
private var valid = false
override var elements: List<E> = emptyList()
private var _value:List<E> = emptyList()
override val value: List<E>
get() {
computeValueAndEvent()
return _value
}
override var changeEvent: ListChangeEvent<E>? = null
get() {
computeValueAndEvent()
return field
}
private set
private fun computeValueAndEvent() {
if (!valid) {
val oldElements = _value
val hasDependents = dependents.isNotEmpty()
val computedCell: ListCell<E>
if (shouldRecomputeCell) {
this.computedCell?.removeDependent(this)
computedCell = computeElements()
if (hasDependents) {
// Only hold onto and depend on the computed cell if we have dependents
// ourselves.
computedCell.addDependent(this)
this.computedCell = computedCell
computedInDeps = dependencies.any { it === computedCell }
shouldRecomputeCell = false
} else {
// Set field to null to allow the cell to be garbage collected.
this.computedCell = null
}
} else {
computedCell = unsafeAssertNotNull(this.computedCell)
}
val newElements = computedCell.value
_value = newElements
changeEvent = ListChangeEvent(
newElements,
listOf(ListChange(
index = 0,
prevSize = oldElements.size,
removed = oldElements,
inserted = newElements,
)),
)
// We stay invalid if we have no dependents to ensure our value is always recomputed.
valid = hasDependents
}
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
super.addDependent(dependent)
if (dependents.size == 1) {
for (dependency in dependencies) {
dependency.addDependent(this)
}
computedCell = computeElements.invoke().also { computedCell ->
computedCell.addDependent(this)
computedInDeps = dependencies.any { it === computedCell }
elements = computedCell.value
}
// Called to ensure that we depend on the computed cell. This could be optimized by
// avoiding the value and changeEvent calculation.
computeValueAndEvent()
}
super.addDependent(dependent)
}
override fun removeDependent(dependent: Dependent) {
super.removeDependent(dependent)
if (dependents.isEmpty()) {
valid = false
computedCell?.removeDependent(this)
// Set field to null to allow the cell to be garbage collected.
computedCell = null
computedInDeps = false
shouldRecomputeCell = true
for (dependency in dependencies) {
dependency.removeDependent(this)
@ -50,26 +105,19 @@ class FlatteningDependentListCell<E>(
}
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if ((dependency !== computedCell || computedInDeps) && event != null) {
shouldRecompute = true
override fun dependencyInvalidated(dependency: Dependency<*>) {
valid = false
// We should recompute the computed cell when any dependency except the computed cell is
// invalidated. When the computed cell is in our dependency array (i.e. the computed cell
// itself takes part in determining what the computed cell is) we should also recompute.
if (dependency !== computedCell || computedInDeps) {
// We're not allowed to change the dependency graph at this point, so we just set this
// field to true and remove ourselves as dependency from the computed cell right before
// we recompute it.
shouldRecomputeCell = true
}
super.dependencyChanged(dependency, event)
}
override fun computeElements() {
if (shouldRecompute || dependents.isEmpty()) {
computedCell?.removeDependent(this)
computedCell = computeElements.invoke().also { computedCell ->
computedCell.addDependent(this)
computedInDeps = dependencies.any { it === computedCell }
}
shouldRecompute = false
}
elements = unsafeAssertNotNull(computedCell).value
emitDependencyInvalidated()
}
}

View File

@ -10,13 +10,15 @@ import world.phantasmal.observable.cell.cell
import world.phantasmal.observable.cell.falseCell
import world.phantasmal.observable.cell.trueCell
class ImmutableListCell<E>(private val elements: List<E>) : Dependency, ListCell<E> {
class ImmutableListCell<E>(private val elements: List<E>) : Dependency<List<E>>, ListCell<E> {
override val size: Cell<Int> = cell(elements.size)
override val empty: Cell<Boolean> = if (elements.isEmpty()) trueCell() else falseCell()
override val notEmpty: Cell<Boolean> = if (elements.isNotEmpty()) trueCell() else falseCell()
override val value: List<E> = elements
override val changeEvent: ListChangeEvent<E>? get() = null
override fun addDependent(dependent: Dependent) {
// We don't remember our dependents because we never need to notify them of changes.
}
@ -25,14 +27,11 @@ class ImmutableListCell<E>(private val elements: List<E>) : Dependency, ListCell
// Nothing to remove because we don't remember our dependents.
}
override fun get(index: Int): E =
elements[index]
override fun get(index: Int): E = elements[index]
override fun observeChange(observer: ChangeObserver<List<E>>): Disposable = nopDisposable()
override fun observeListChange(observer: ListChangeObserver<E>): Disposable = nopDisposable()
override fun emitDependencyChanged() {
error("ImmutableListCell can't change.")
}
override fun toString(): String = listCellToString(this)
}

View File

@ -6,6 +6,8 @@ import world.phantasmal.observable.cell.Cell
interface ListCell<out E> : Cell<List<E>> {
override val value: List<E>
override val changeEvent: ListChangeEvent<E>?
val size: Cell<Int>
val empty: Cell<Boolean>

View File

@ -68,6 +68,14 @@ fun <T1, T2, R> mapToList(
): ListCell<R> =
DependentListCell(c1, c2) { transform(c1.value, c2.value) }
fun <T1, T2, T3, R> mapToList(
c1: Cell<T1>,
c2: Cell<T2>,
c3: Cell<T3>,
transform: (T1, T2, T3) -> List<R>,
): ListCell<R> =
DependentListCell(c1, c2, c3) { transform(c1.value, c2.value, c3.value) }
fun <T, R> Cell<T>.flatMapToList(
transform: (T) -> ListCell<R>,
): ListCell<R> =
@ -79,3 +87,12 @@ fun <T1, T2, R> flatMapToList(
transform: (T1, T2) -> ListCell<R>,
): ListCell<R> =
FlatteningDependentListCell(c1, c2) { transform(c1.value, c2.value) }
fun listCellToString(cell: ListCell<*>): String = buildString {
append(this::class.simpleName)
append('[')
cell.value.joinTo(this, limit = 20) {
if (it === this) "(this cell)" else it.toString()
}
append(']')
}

View File

@ -1,7 +1,6 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.splice
import world.phantasmal.core.unsafe.unsafeCast
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
@ -9,28 +8,70 @@ import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.AbstractCell
/**
* Depends on a [ListCell] and zero or more [Observable]s per element in the list.
* Depends on a [ListCell] and zero or more observables per element in the list.
*/
class ListElementsDependentCell<E>(
private val list: ListCell<E>,
private val extractObservables: (element: E) -> Array<out Observable<*>>,
) : AbstractCell<List<E>>(), Dependent {
/** An array of dependencies per [list] element, extracted by [extractObservables]. */
private val elementDependencies = mutableListOf<Array<out Dependency>>()
private val elementDependencies = mutableListOf<Array<out Dependency<*>>>()
/** Keeps track of how many of our dependencies are about to (maybe) change. */
private var changingDependencies = 0
/**
* Set to true once one of our dependencies has actually changed. Reset to false whenever
* [changingDependencies] hits 0 again.
*/
private var dependenciesActuallyChanged = false
private var listChangeEvent: ListChangeEvent<E>? = null
private var valid = false
private var listInvalidated = false
override val value: List<E>
get() = list.value
get() {
updateElementDependenciesAndEvent()
return list.value
}
override var changeEvent: ChangeEvent<List<E>>? = null
get() {
updateElementDependenciesAndEvent()
return field
}
private set
private fun updateElementDependenciesAndEvent() {
if (!valid) {
if (listInvalidated) {
// At this point we can remove this dependent from the removed elements' dependencies
// and add it to the newly inserted elements' dependencies.
list.changeEvent?.let { listChangeEvent ->
for (change in listChangeEvent.changes) {
for (i in change.index until (change.index + change.removed.size)) {
for (elementDependency in elementDependencies[i]) {
elementDependency.removeDependent(this)
}
}
val inserted = change.inserted.map(extractObservables)
elementDependencies.splice(
startIndex = change.index,
amount = change.removed.size,
elements = inserted,
)
for (elementDependencies in inserted) {
for (elementDependency in elementDependencies) {
elementDependency.addDependent(this)
}
}
}
}
// Reset for the next change wave.
listInvalidated = false
}
changeEvent = ChangeEvent(list.value)
// We stay invalid if we have no dependents to ensure our change event is always
// recomputed.
valid = dependents.isNotEmpty()
}
}
override fun addDependent(dependent: Dependent) {
if (dependents.isEmpty()) {
@ -55,6 +96,9 @@ class ListElementsDependentCell<E>(
super.removeDependent(dependent)
if (dependents.isEmpty()) {
valid = false
listInvalidated = false
// At this point we have no more dependents, so we can stop depending on our own
// dependencies.
for (dependencies in elementDependencies) {
@ -68,73 +112,13 @@ class ListElementsDependentCell<E>(
}
}
override fun dependencyMightChange() {
changingDependencies++
emitMightChange()
}
override fun dependencyInvalidated(dependency: Dependency<*>) {
valid = false
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
if (event != null) {
dependenciesActuallyChanged = true
// Simply store all list changes when the changing dependency is our list dependency. We
// don't update our dependencies yet to avoid receiving dependencyChanged notifications
// from newly inserted dependencies for which we haven't received any
// dependencyMightChange notifications and to avoid *NOT* receiving dependencyChanged
// notifications from removed dependencies for which we *HAVE* received
// dependencyMightChange notifications.
if (dependency === list) {
listChangeEvent = unsafeCast(event)
}
if (dependency === list) {
listInvalidated = true
}
changingDependencies--
if (changingDependencies == 0) {
// All of our dependencies have finished changing.
// At this point we can remove this dependent from the removed elements' dependencies
// and add it to the newly inserted elements' dependencies.
listChangeEvent?.let { listChangeEvent ->
for (change in listChangeEvent.changes) {
for (i in change.index until (change.index + change.removed.size)) {
for (elementDependency in elementDependencies[i]) {
elementDependency.removeDependent(this)
}
}
val inserted = change.inserted.map(extractObservables)
elementDependencies.splice(
startIndex = change.index,
amount = change.removed.size,
elements = inserted,
)
for (elementDependencies in inserted) {
for (elementDependency in elementDependencies) {
elementDependency.addDependent(this)
}
}
}
}
// Reset for the next change wave.
listChangeEvent = null
if (dependenciesActuallyChanged) {
dependenciesActuallyChanged = false
emitDependencyChangedEvent(ChangeEvent(list.value))
} else {
emitDependencyChangedEvent(null)
}
}
}
override fun emitDependencyChanged() {
// Nothing to do because ListElementsDependentCell emits dependencyChanged immediately. We
// don't defer this operation because ListElementsDependentCell only changes when there is
// no transaction or the current transaction is being committed.
emitDependencyInvalidated()
}
}

View File

@ -1,5 +1,6 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.assertUnreachable
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.cell.Cell
@ -17,12 +18,11 @@ class SimpleFilteredListCell<E>(
*/
private val indexMap = mutableListOf<Int>()
override val predicateDependency: Dependency
override val predicateDependency: Dependency<*>
get() = predicate
override fun otherDependencyChanged(dependency: Dependency) {
// Unreachable code path.
error("Unexpected dependency.")
override fun otherDependencyInvalidated(dependency: Dependency<*>) {
assertUnreachable { "Unexpected dependency $dependency." }
}
override fun ignoreOtherChanges() {

View File

@ -1,16 +1,14 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.replaceAll
import world.phantasmal.observable.ChangeManager
/**
* @param elements The backing list for this [ListCell].
*/
// TODO: Support change sets by sometimes appending to changeEvent instead of always overwriting it.
class SimpleListCell<E>(
override val elements: MutableList<E>,
) : AbstractListCell<E>(), MutableListCell<E> {
private var changes = mutableListOf<ListChange<E>>()
) : AbstractElementsWrappingListCell<E>(), MutableListCell<E> {
override var value: List<E>
get() = elementsWrapper
@ -18,50 +16,55 @@ class SimpleListCell<E>(
replaceAll(value)
}
override var changeEvent: ListChangeEvent<E>? = null
private set
override operator fun get(index: Int): E =
elements[index]
override operator fun set(index: Int, element: E): E {
checkIndex(index, elements.lastIndex)
emitMightChange()
copyAndResetWrapper()
val removed = elements.set(index, element)
applyChange {
copyAndResetWrapper()
val removed = elements.set(index, element)
finalizeChange(
index,
prevSize = elements.size,
removed = listOf(removed),
inserted = listOf(element),
)
finalizeChange(
index,
prevSize = elements.size,
removed = listOf(removed),
inserted = listOf(element),
)
return removed
return removed
}
}
override fun add(element: E) {
emitMightChange()
applyChange {
val index = elements.size
copyAndResetWrapper()
elements.add(element)
val index = elements.size
copyAndResetWrapper()
elements.add(element)
finalizeChange(
index,
prevSize = index,
removed = emptyList(),
inserted = listOf(element),
)
finalizeChange(
index,
prevSize = index,
removed = emptyList(),
inserted = listOf(element),
)
}
}
override fun add(index: Int, element: E) {
val prevSize = elements.size
checkIndex(index, prevSize)
emitMightChange()
copyAndResetWrapper()
elements.add(index, element)
applyChange {
copyAndResetWrapper()
elements.add(index, element)
finalizeChange(index, prevSize, removed = emptyList(), inserted = listOf(element))
finalizeChange(index, prevSize, removed = emptyList(), inserted = listOf(element))
}
}
override fun remove(element: E): Boolean {
@ -77,99 +80,95 @@ class SimpleListCell<E>(
override fun removeAt(index: Int): E {
checkIndex(index, elements.lastIndex)
emitMightChange()
val prevSize = elements.size
applyChange {
val prevSize = elements.size
copyAndResetWrapper()
val removed = elements.removeAt(index)
copyAndResetWrapper()
val removed = elements.removeAt(index)
finalizeChange(index, prevSize, removed = listOf(removed), inserted = emptyList())
return removed
finalizeChange(index, prevSize, removed = listOf(removed), inserted = emptyList())
return removed
}
}
override fun replaceAll(elements: Iterable<E>) {
emitMightChange()
applyChange {
val prevSize = this.elements.size
val removed = elementsWrapper
val prevSize = this.elements.size
val removed = elementsWrapper
copyAndResetWrapper()
this.elements.replaceAll(elements)
copyAndResetWrapper()
this.elements.replaceAll(elements)
finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
}
}
override fun replaceAll(elements: Sequence<E>) {
emitMightChange()
applyChange {
val prevSize = this.elements.size
val removed = elementsWrapper
val prevSize = this.elements.size
val removed = elementsWrapper
copyAndResetWrapper()
this.elements.replaceAll(elements)
copyAndResetWrapper()
this.elements.replaceAll(elements)
finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
finalizeChange(index = 0, prevSize, removed, inserted = elementsWrapper)
}
}
override fun splice(fromIndex: Int, removeCount: Int, newElement: E) {
val prevSize = elements.size
val removed = ArrayList<E>(removeCount)
// Do this loop outside applyChange because it will throw when any index is out of bounds.
for (i in fromIndex until (fromIndex + removeCount)) {
removed.add(elements[i])
}
emitMightChange()
applyChange {
copyAndResetWrapper()
repeat(removeCount) { elements.removeAt(fromIndex) }
elements.add(fromIndex, newElement)
copyAndResetWrapper()
repeat(removeCount) { elements.removeAt(fromIndex) }
elements.add(fromIndex, newElement)
finalizeChange(fromIndex, prevSize, removed, inserted = listOf(newElement))
finalizeChange(fromIndex, prevSize, removed, inserted = listOf(newElement))
}
}
override fun clear() {
emitMightChange()
applyChange {
val prevSize = elements.size
val removed = elementsWrapper
val prevSize = elements.size
val removed = elementsWrapper
copyAndResetWrapper()
elements.clear()
copyAndResetWrapper()
elements.clear()
finalizeChange(index = 0, prevSize, removed, inserted = emptyList())
finalizeChange(index = 0, prevSize, removed, inserted = emptyList())
}
}
override fun sortWith(comparator: Comparator<E>) {
emitMightChange()
applyChange {
val removed = elementsWrapper
copyAndResetWrapper()
var throwable: Throwable? = null
val removed = elementsWrapper
copyAndResetWrapper()
var throwable: Throwable? = null
try {
elements.sortWith(comparator)
} catch (e: Throwable) {
throwable = e
}
try {
elements.sortWith(comparator)
} catch (e: Throwable) {
throwable = e
finalizeChange(
index = 0,
prevSize = elements.size,
removed,
inserted = elementsWrapper,
)
if (throwable != null) {
throw throwable
}
}
finalizeChange(
index = 0,
prevSize = elements.size,
removed,
inserted = elementsWrapper,
)
if (throwable != null) {
throw throwable
}
}
override fun emitDependencyChanged() {
val currentChanges = changes
changes = mutableListOf()
emitDependencyChangedEvent(ListChangeEvent(elementsWrapper, currentChanges))
}
private fun checkIndex(index: Int, maxIndex: Int) {
@ -186,7 +185,9 @@ class SimpleListCell<E>(
removed: List<E>,
inserted: List<E>,
) {
changes.add(ListChange(index, prevSize, removed, inserted))
ChangeManager.changed(this)
changeEvent = ListChangeEvent(
elementsWrapper,
listOf(ListChange(index, prevSize, removed, inserted)),
)
}
}

View File

@ -7,44 +7,31 @@ interface DependencyTests : ObservableTestSuite {
fun createProvider(): Provider
@Test
fun correctly_emits_changes_to_its_dependents() = test {
fun correctly_emits_invalidation_notifications_to_its_dependents() = test {
val p = createProvider()
var dependencyMightChangeCalled = false
var dependencyChangedCalled = false
var dependencyInvalidatedCalled: Boolean
p.dependency.addDependent(object : Dependent {
override fun dependencyMightChange() {
assertFalse(dependencyMightChangeCalled)
assertFalse(dependencyChangedCalled)
dependencyMightChangeCalled = true
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
assertTrue(dependencyMightChangeCalled)
assertFalse(dependencyChangedCalled)
override fun dependencyInvalidated(dependency: Dependency<*>) {
assertEquals(p.dependency, dependency)
assertNotNull(event)
dependencyChangedCalled = true
dependencyInvalidatedCalled = true
}
})
repeat(5) { index ->
dependencyMightChangeCalled = false
dependencyChangedCalled = false
dependencyInvalidatedCalled = false
p.emit()
assertTrue(dependencyMightChangeCalled, "repetition $index")
assertTrue(dependencyChangedCalled, "repetition $index")
assertTrue(dependencyInvalidatedCalled, "repetition $index")
}
}
interface Provider {
val dependency: Dependency
val dependency: Dependency<*>
/**
* Makes [dependency] emit [Dependent.dependencyMightChange] followed by
* [Dependent.dependencyChanged] with a non-null event.
* Makes [dependency] call [Dependent.dependencyInvalidated] on its dependents.
*/
fun emit()
}

View File

@ -57,6 +57,6 @@ interface ObservableTests : DependencyTests {
interface Provider : DependencyTests.Provider {
val observable: Observable<*>
override val dependency: Dependency get() = observable
override val dependency: Dependency<*> get() = observable
}
}

View File

@ -3,7 +3,9 @@ package world.phantasmal.observable.cell
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Dependency
import world.phantasmal.observable.Dependent
import kotlin.test.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
interface CellWithDependenciesTests : CellTests {
fun createWithDependencies(
@ -13,39 +15,26 @@ interface CellWithDependenciesTests : CellTests {
): Cell<Any>
@Test
fun emits_precisely_once_when_all_of_its_dependencies_emit() = test {
fun emits_at_least_once_when_all_of_its_dependencies_emit() = test {
val root = SimpleCell(5)
val branch1 = DependentCell(root) { root.value * 2 }
val branch2 = DependentCell(root) { root.value * 3 }
val branch3 = DependentCell(root) { root.value * 4 }
val leaf = createWithDependencies(branch1, branch2, branch3)
var dependencyMightChangeCalled = false
var dependencyChangedCalled = false
var dependencyInvalidatedCalled: Boolean
leaf.addDependent(object : Dependent {
override fun dependencyMightChange() {
assertFalse(dependencyMightChangeCalled)
assertFalse(dependencyChangedCalled)
dependencyMightChangeCalled = true
}
override fun dependencyChanged(dependency: Dependency, event: ChangeEvent<*>?) {
assertTrue(dependencyMightChangeCalled)
assertFalse(dependencyChangedCalled)
assertEquals(leaf, dependency)
assertNotNull(event)
dependencyChangedCalled = true
override fun dependencyInvalidated(dependency: Dependency<*>) {
dependencyInvalidatedCalled = true
}
})
repeat(5) { index ->
dependencyMightChangeCalled = false
dependencyChangedCalled = false
dependencyInvalidatedCalled = false
root.value += 1
assertTrue(dependencyMightChangeCalled, "repetition $index")
assertTrue(dependencyChangedCalled, "repetition $index")
assertTrue(dependencyInvalidatedCalled, "repetition $index")
}
}
@ -91,10 +80,6 @@ interface CellWithDependenciesTests : CellTests {
val publicDependents: List<Dependent> = dependents
override val value: Int = 5
override fun emitDependencyChanged() {
// Not going to change.
throw NotImplementedError()
}
override val changeEvent: ChangeEvent<Int> = ChangeEvent(value)
}
}

View File

@ -0,0 +1,20 @@
package world.phantasmal.observable.cell
/**
* In these tests both the direct dependency and the transitive dependency of the
* [FlatteningDependentCell] change.
*/
class FlatteningDependentCellDirectAndTransitiveDependencyEmitTests : CellTests {
override fun createProvider() = Provider()
class Provider : CellTests.Provider {
// This cell is both the direct and transitive dependency.
private val dependencyCell = SimpleCell('a')
override val observable = FlatteningDependentCell(dependencyCell) { dependencyCell }
override fun emit() {
dependencyCell.value += 1
}
}
}

View File

@ -1,17 +0,0 @@
package world.phantasmal.observable.cell
import world.phantasmal.observable.cell.list.SimpleListCell
class FlatteningDependentCellWithSimpleListCellTests : CellTests {
override fun createProvider() = Provider()
class Provider : CellTests.Provider {
private val dependencyCell = SimpleListCell(mutableListOf("a", "b", "c"))
override val observable = FlatteningDependentCell(dependencyCell) { dependencyCell }
override fun emit() {
dependencyCell.add("x")
}
}
}

View File

@ -1,16 +1,19 @@
package world.phantasmal.observable.cell
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.disposable.DisposableTracking
import world.phantasmal.observable.test.ObservableTestSuite
import kotlin.test.Test
import kotlin.test.assertEquals
class ImmutableCellTests : ObservableTestSuite {
/**
* As an optimization we simply ignore any observers and return a singleton Nop disposable.
*/
@Test
fun observing_it_never_creates_leaks() = test {
val cell = ImmutableCell("test value")
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
// We never call dispose on the returned disposable.
cell.observeChange {}
}

View File

@ -70,7 +70,7 @@ interface MutableCellTests<T : Any> : CellTests {
// TODO: Figure out change set bug and enable change sets again.
/**
* Modifying a mutable cell multiple times in one change set results in a single call to
* [Dependent.dependencyMightChange] and [Dependent.dependencyChanged].
* [Dependent.dependencyInvalidated] and [Dependent.dependencyChanged].
*/
// @Test
// fun multiple_changes_to_one_cell_in_change_set() = test {

View File

@ -1,6 +1,9 @@
package world.phantasmal.observable.cell
import kotlin.test.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Test suite for all [Cell] implementations that aren't ListCells. There is a subclass of this
@ -9,6 +12,7 @@ import kotlin.test.*
interface RegularCellTests : CellTests {
fun <T> createWithValue(value: T): Cell<T>
// TODO: Move this test to CellTests.
@Test
fun convenience_methods() = test {
listOf(Any(), null).forEach { any ->
@ -25,6 +29,7 @@ interface RegularCellTests : CellTests {
}
}
// TODO: Move this test to CellTests.
@Test
fun generic_extensions() = test {
listOf(Any(), null).forEach { any ->
@ -54,6 +59,9 @@ interface RegularCellTests : CellTests {
assertEquals(a != b, (aCell ne bCell).value)
}
testEqNe(null, null)
testEqNe(null, Unit)
testEqNe(Unit, Unit)
testEqNe(10, 10)
testEqNe(5, 99)
testEqNe("a", "a")

View File

@ -1,12 +1,8 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.ImmutableCell
import world.phantasmal.observable.cell.*
// TODO: A test suite that tests FilteredListCell while its predicate dependency is changing.
// TODO: A test suite that tests FilteredListCell while the predicate results are changing.
class FilteredListCellListDependencyEmitsTests : AbstractFilteredListCellTests {
class FilteredListCellListDependencyEmitsTests : ListCellTests, CellWithDependenciesTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private val dependencyCell =
SimpleListCell(if (empty) mutableListOf(5) else mutableListOf(5, 10))
@ -14,7 +10,7 @@ class FilteredListCellListDependencyEmitsTests : AbstractFilteredListCellTests {
override val observable =
FilteredListCell(
list = dependencyCell,
predicate = ImmutableCell { ImmutableCell(it % 2 == 0) },
predicate = cell { cell(it % 2 == 0) },
)
override fun addElement() {
@ -28,14 +24,12 @@ class FilteredListCellListDependencyEmitsTests : AbstractFilteredListCellTests {
dependency3: Cell<Int>,
) =
FilteredListCell(
list = DependentListCell(dependency1, computeElements = {
listOf(dependency1.value)
}),
predicate = DependentCell(dependency2, compute = {
fun predicate(element: Int) =
DependentCell(dependency3, compute = { element < dependency2.value })
list = dependency1.mapToList { listOf(it) },
predicate = dependency2.map { value2 ->
fun predicate(element: Int): Cell<Boolean> =
dependency3.map { value3 -> (element % 2) == ((value2 + value3) % 2) }
::predicate
}),
},
)
}

View File

@ -0,0 +1,13 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.cell
import world.phantasmal.observable.cell.map
// TODO: A test suite that tests FilteredListCell while its predicate dependency is changing.
// TODO: A test suite that tests FilteredListCell while the predicate results are changing.
// TODO: A test suite that tests FilteredListCell while all 3 types of dependencies are changing.
class FilteredListCellTests : SuperFilteredListCellTests {
override fun <E> createFilteredListCell(list: ListCell<E>, predicate: Cell<(E) -> Boolean>) =
FilteredListCell(list, predicate.map { p -> { cell(p(it)) } })
}

View File

@ -1,17 +1,20 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.disposable.DisposableTracking
import world.phantasmal.observable.cell.observeNow
import world.phantasmal.observable.test.ObservableTestSuite
import kotlin.test.Test
import kotlin.test.assertEquals
class ImmutableListCellTests : ObservableTestSuite {
/**
* As an optimization we simply ignore any observers and return a singleton Nop disposable.
*/
@Test
fun observing_it_never_creates_leaks() = test {
val listCell = ImmutableListCell(listOf(1, 2, 3))
TrackedDisposable.checkNoLeaks {
DisposableTracking.checkNoLeaks {
// We never call dispose on the returned disposables.
listCell.observeChange {}
listCell.observeListChange {}

View File

@ -1,21 +1,23 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.ImmutableCell
import world.phantasmal.observable.cell.CellWithDependenciesTests
import world.phantasmal.observable.cell.cell
/**
* In these tests the list dependency of the [SimpleListCell] changes and the predicate
* dependency does not.
*/
class SimpleFilteredListCellListDependencyEmitsTests : AbstractFilteredListCellTests {
class SimpleFilteredListCellListDependencyEmitsTests :
ListCellTests, CellWithDependenciesTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private val dependencyCell =
SimpleListCell(if (empty) mutableListOf(5) else mutableListOf(5, 10))
override val observable = SimpleFilteredListCell(
list = dependencyCell,
predicate = ImmutableCell { it % 2 == 0 },
predicate = cell { it % 2 == 0 },
)
override fun addElement() {
@ -29,11 +31,9 @@ class SimpleFilteredListCellListDependencyEmitsTests : AbstractFilteredListCellT
dependency3: Cell<Int>,
) =
SimpleFilteredListCell(
list = DependentListCell(dependency1, dependency2) {
listOf(dependency1.value, dependency2.value)
},
predicate = DependentCell(dependency3) {
{ it < dependency3.value }
list = mapToList(dependency1, dependency2, dependency3) { value1, value2, value3 ->
listOf(value1, value2, value3)
},
predicate = cell { it % 2 == 0 },
)
}

View File

@ -1,14 +1,17 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.DependentCell
import world.phantasmal.observable.cell.CellWithDependenciesTests
import world.phantasmal.observable.cell.SimpleCell
import world.phantasmal.observable.cell.map
/**
* In these tests the predicate dependency of the [SimpleListCell] changes and the list dependency
* does not.
*/
class SimpleFilteredListCellPredicateDependencyEmitsTests : AbstractFilteredListCellTests {
class SimpleFilteredListCellPredicateDependencyEmitsTests :
ListCellTests, CellWithDependenciesTests {
override fun createListProvider(empty: Boolean) = object : ListCellTests.Provider {
private var maxValue = if (empty) 0 else 1
private val predicateCell = SimpleCell<(Int) -> Boolean> { it <= maxValue }
@ -31,11 +34,9 @@ class SimpleFilteredListCellPredicateDependencyEmitsTests : AbstractFilteredList
dependency3: Cell<Int>,
) =
SimpleFilteredListCell(
list = DependentListCell(dependency1, dependency2) {
listOf(dependency1.value, dependency2.value)
},
predicate = DependentCell(dependency3) {
{ it < dependency3.value }
list = listCell(1, 2, 3, 4, 5, 6, 7, 8, 9),
predicate = map(dependency1, dependency2, dependency3) { value1, value2, value3 ->
{ (it % 2) == ((value1 + value2 + value3) % 2) }
},
)
}

View File

@ -0,0 +1,10 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.cell.Cell
// TODO: A test suite that tests SimpleFilteredListCell while both types of dependencies are
// changing.
class SimpleFilteredListCellTests : SuperFilteredListCellTests {
override fun <E> createFilteredListCell(list: ListCell<E>, predicate: Cell<(E) -> Boolean>) =
SimpleFilteredListCell(list, predicate)
}

View File

@ -1,16 +1,21 @@
package world.phantasmal.observable.cell.list
import world.phantasmal.observable.ChangeManager
import world.phantasmal.observable.cell.CellWithDependenciesTests
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.ImmutableCell
import world.phantasmal.observable.cell.SimpleCell
import world.phantasmal.observable.test.ObservableTestSuite
import kotlin.test.*
interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTests {
/**
* Tests that apply to all filtered list implementations.
*/
interface SuperFilteredListCellTests : ObservableTestSuite {
fun <E> createFilteredListCell(list: ListCell<E>, predicate: Cell<(E) -> Boolean>): ListCell<E>
@Test
fun contains_only_values_that_match_the_predicate() = test {
val dep = SimpleListCell(mutableListOf("a", "b"))
val list = SimpleFilteredListCell(dep, predicate = ImmutableCell { 'a' in it })
val list = createFilteredListCell(dep, predicate = ImmutableCell { 'a' in it })
assertEquals(1, list.value.size)
assertEquals("a", list.value[0])
@ -34,7 +39,7 @@ interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTes
@Test
fun only_emits_when_necessary() = test {
val dep = SimpleListCell<Int>(mutableListOf())
val list = SimpleFilteredListCell(dep, predicate = ImmutableCell { it % 2 == 0 })
val list = createFilteredListCell(dep, predicate = ImmutableCell { it % 2 == 0 })
var changes = 0
var listChanges = 0
@ -63,7 +68,7 @@ interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTes
@Test
fun emits_correct_change_events() = test {
val dep = SimpleListCell<Int>(mutableListOf())
val list = SimpleFilteredListCell(dep, predicate = ImmutableCell { it % 2 == 0 })
val list = createFilteredListCell(dep, predicate = ImmutableCell { it % 2 == 0 })
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
@ -107,7 +112,7 @@ interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTes
@Test
fun value_changes_and_emits_when_predicate_changes() = test {
val predicate: SimpleCell<(Int) -> Boolean> = SimpleCell { it % 2 == 0 }
val list = SimpleFilteredListCell(ImmutableListCell(listOf(1, 2, 3, 4, 5)), predicate)
val list = createFilteredListCell(ImmutableListCell(listOf(1, 2, 3, 4, 5)), predicate)
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
@ -157,38 +162,31 @@ interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTes
@Test
fun emits_correctly_when_multiple_changes_happen_at_once() = test {
val dependency = object : AbstractListCell<Int>() {
private val changes: MutableList<Pair<Int, Int>> = mutableListOf()
override val elements: MutableList<Int> = mutableListOf()
override val value = elements
override fun emitDependencyChanged() {
emitDependencyChangedEvent(ListChangeEvent(
elementsWrapper,
changes.map { (index, newElement) ->
ListChange(
index = index,
prevSize = index,
removed = emptyList(),
inserted = listOf(newElement),
)
}
))
changes.clear()
}
private val elements: MutableList<Int> = mutableListOf()
override val value: List<Int> get() = elements
override var changeEvent: ListChangeEvent<Int>? = null
private set
fun makeChanges(newElements: List<Int>) {
emitMightChange()
applyChange {
val changes: MutableList<ListChange<Int>> = mutableListOf()
for (newElement in newElements) {
changes.add(Pair(elements.size, newElement))
elements.add(newElement)
for (newElement in newElements) {
changes.add(ListChange(
index = elements.size,
prevSize = elements.size,
removed = emptyList(),
inserted = listOf(newElement),
))
elements.add(newElement)
}
changeEvent = ListChangeEvent(elements.toList(), changes)
}
ChangeManager.changed(this)
}
}
val list = SimpleFilteredListCell(dependency, ImmutableCell { true })
val list = createFilteredListCell(dependency, ImmutableCell { true })
var event: ListChangeEvent<Int>? = null
disposer.add(list.observeListChange {
@ -235,7 +233,7 @@ interface AbstractFilteredListCellTests : ListCellTests, CellWithDependenciesTes
val y = "y"
val z = "z"
val dependency = SimpleListCell(mutableListOf(x, y, z, x, y, z))
val list = SimpleFilteredListCell(dependency, SimpleCell { it != y })
val list = createFilteredListCell(dependency, SimpleCell { it != y })
var event: ListChangeEvent<String>? = null
disposer.add(list.observeListChange {

View File

@ -5,6 +5,5 @@ import kotlin.test.assertEquals
fun <E> assertListCellEquals(expected: List<E>, actual: ListCell<E>) {
assertEquals(expected.size, actual.size.value)
assertEquals(expected.size, actual.value.size)
assertEquals(expected, actual.value)
}

View File

@ -1,16 +1,16 @@
package world.phantasmal.testUtils
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.disposable.DisposableTracking
interface AbstractTestSuite<Ctx : TestContext> {
fun test(slow: Boolean = false, testBlock: Ctx.() -> Unit) {
if (slow && !canExecuteSlowTests()) return
TrackedDisposable.checkNoLeaks(trackPrecise = true) {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
testBlock(createContext(disposer))
createContext(disposer).testBlock()
disposer.dispose()
}
@ -20,10 +20,10 @@ interface AbstractTestSuite<Ctx : TestContext> {
world.phantasmal.testUtils.testAsync lambda@{
if (slow && !canExecuteSlowTests()) return@lambda
TrackedDisposable.checkNoLeaks(trackPrecise = true) {
DisposableTracking.checkNoLeaks {
val disposer = Disposer()
testBlock(createContext(disposer))
createContext(disposer).testBlock()
disposer.dispose()
}

View File

@ -15,7 +15,6 @@ import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.web.application.Application
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.persistence.LocalStorageKeyValueStore
@ -91,11 +90,21 @@ private fun createThreeRenderer(canvas: HTMLCanvasElement): DisposableThreeRende
private class HistoryApplicationUrl : TrackedDisposable(), ApplicationUrl {
private val path: String get() = window.location.pathname
private val popCallbacks = mutableListOf<(String) -> Unit>()
override val url = mutableCell(window.location.hash.substring(1))
override var pathAndParams = window.location.hash.substring(1)
private set
private val popStateListener = window.disposableListener<PopStateEvent>("popstate", {
url.value = window.location.hash.substring(1)
val newPathAndParams = window.location.hash.substring(1)
if (newPathAndParams != pathAndParams) {
pathAndParams = newPathAndParams
for (callback in popCallbacks) {
callback(newPathAndParams)
}
}
})
override fun dispose() {
@ -103,18 +112,19 @@ private class HistoryApplicationUrl : TrackedDisposable(), ApplicationUrl {
super.dispose()
}
override fun pushUrl(url: String) {
window.history.pushState(null, TITLE, "$path#$url")
// Do after pushState to avoid triggering observers that call pushUrl or replaceUrl before
// the current change has happened.
this.url.value = url
override fun pushPathAndParams(pathAndParams: String) {
this.pathAndParams = pathAndParams
window.history.pushState(null, TITLE, "$path#$pathAndParams")
}
override fun replaceUrl(url: String) {
window.history.replaceState(null, TITLE, "$path#$url")
// Do after replaceState to avoid triggering observers that call pushUrl or replaceUrl
// before the current change has happened.
this.url.value = url
override fun replacePathAndParams(pathAndParams: String) {
this.pathAndParams = pathAndParams
window.history.replaceState(null, TITLE, "$path#$pathAndParams")
}
override fun onPopPathAndParams(callback: (String) -> Unit): Disposable {
popCallbacks.add(callback)
return disposable { popCallbacks.remove(callback) }
}
companion object {

View File

@ -1,6 +1,6 @@
package world.phantasmal.web.core.actions
package world.phantasmal.web.core.commands
interface Action {
interface Command {
val description: String
fun execute()
fun undo()

View File

@ -1,5 +1,8 @@
package world.phantasmal.web.core.controllers
import kotlinx.browser.window
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.map
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Tab
@ -12,33 +15,40 @@ interface PathAwareTab : Tab {
open class PathAwareTabContainerController<T : PathAwareTab>(
private val uiStore: UiStore,
private val tool: PwToolType,
tabs: List<T>,
) : TabContainerController<T>(tabs) {
init {
observeNow(uiStore.path) { path ->
if (uiStore.currentTool.value == tool) {
tabs.find { path.startsWith(it.path) }?.let {
setActiveTab(it, replaceUrl = true)
}
final override val tabs: List<T>,
) : TabContainerController<T>() {
final override val activeTab: Cell<T?> =
map(uiStore.currentTool, uiStore.path) { currentTool, path ->
if (currentTool == tool) {
tabs.find { path.startsWith(it.path) } ?: tabs.firstOrNull()
} else {
null
}
}
init {
setPathPrefix(activeTab.value, replace = true)
}
override fun setActiveTab(tab: T?, replaceUrl: Boolean) {
if (tab != null && uiStore.currentTool.value == tool) {
uiStore.setPathPrefix(tab.path, replaceUrl)
}
super.setActiveTab(tab)
final override fun setActiveTab(tab: T?) {
setPathPrefix(tab, replace = false)
}
override fun visibleChanged(visible: Boolean) {
final override fun visibleChanged(visible: Boolean) {
super.visibleChanged(visible)
if (visible && uiStore.currentTool.value == tool) {
activeTab.value?.let {
uiStore.setPathPrefix(it.path, replace = true)
}
if (visible) {
// TODO: Remove this hack.
window.setTimeout({
if (disposed) return@setTimeout
setPathPrefix(activeTab.value, replace = true)
}, 0)
}
}
private fun setPathPrefix(tab: T?, replace: Boolean) {
if (tab != null && uiStore.currentTool.value == tool) {
uiStore.setPathPrefix(tab.path, replace)
}
}
}

View File

@ -5,12 +5,14 @@ import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.browser.window
import kotlinx.coroutines.delay
import org.khronos.webgl.ArrayBuffer
import world.phantasmal.web.shared.dto.QuestDto
class AssetLoader(
val httpClient: HttpClient,
val origin: String = window.location.origin,
val basePath: String = window.location.pathname.removeSuffix("/") + "/assets",
val basePath: String = defaultBasePath(),
) {
suspend inline fun <reified T> load(path: String): T =
httpClient.get("$origin$basePath$path")
@ -23,4 +25,19 @@ class AssetLoader(
check(channel.availableForRead == 0) { "Couldn't read all data." }
return arrayBuffer
}
companion object {
fun defaultBasePath(): String {
val pathname = window.location.pathname
val appPath =
if (pathname.endsWith(".html")) {
pathname.substring(0, pathname.lastIndexOf('/'))
} else {
pathname.removeSuffix("/")
}
return "$appPath/assets"
}
}
}

View File

@ -4,150 +4,96 @@ import kotlinx.browser.window
import kotlinx.coroutines.launch
import org.w3c.dom.events.KeyboardEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.MutableCell
import world.phantasmal.observable.cell.eq
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.models.Server
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.stores.Store
/**
* Represents the current path and parameters without hash (#). E.g. `/viewer/models?model=HUmar`.
* In production this string is actually appended to the base URL after the hash. The above path and
* parameters would become `https://www.phantasmal.world/#/viewer/models?model=HUmar`.
*/
interface ApplicationUrl {
val url: Cell<String>
val pathAndParams: String
fun pushUrl(url: String)
fun pushPathAndParams(pathAndParams: String)
fun replaceUrl(url: String)
fun replacePathAndParams(pathAndParams: String)
fun onPopPathAndParams(callback: (String) -> Unit): Disposable
}
class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
private val _currentTool: MutableCell<PwToolType>
interface Param : Disposable {
val value: String?
private val _path = mutableCell("")
fun set(value: String?)
}
class UiStore(applicationUrl: ApplicationUrl) : Store() {
private val _server = mutableCell(Server.Ephinea)
/**
* Maps full paths to maps of parameters and their values. In other words we keep track of
* parameter values per [applicationUrl].
*/
private val parameters: MutableMap<String, MutableMap<String, MutableCell<String?>>> =
mutableMapOf()
// TODO: Remove this dependency and add it to each component that actually needs it.
private val navigationStore = addDisposable(NavigationStore(applicationUrl))
private val globalKeyDownHandlers: MutableMap<String, suspend (e: KeyboardEvent) -> Unit> =
mutableMapOf()
/**
* Enabled alpha features. Alpha features can be turned on by adding a features parameter with
* the comma-separated feature names as value.
* E.g. `/viewer?features=f1,f2,f3`
*/
private val features: MutableSet<String> = mutableSetOf()
private val tools: List<PwToolType> = PwToolType.values().toList()
/**
* The default tool that is loaded.
*/
val defaultTool: PwToolType = PwToolType.Viewer
/**
* The tool that is currently visible.
*/
val currentTool: Cell<PwToolType>
val currentTool: Cell<PwToolType> get() = navigationStore.currentTool
/**
* Map of tools to a boolean cell that says whether they are the current tool or not.
* At all times, exactly one of these booleans is true.
*/
val toolToActive: Map<PwToolType, Cell<Boolean>>
val toolToActive: Map<PwToolType, Cell<Boolean>> =
tools.associateWith { tool -> currentTool eq tool }
/**
* Application URL without the tool path prefix.
* E.g. when the full path is `/viewer/models`, [path] will be `/models`.
*/
val path: Cell<String> = _path
val path: Cell<String> get() = navigationStore.path
/**
* The private server we're currently showing data and tools for.
*/
val server: Cell<Server> = _server
val server: Cell<Server> get() = _server
init {
_currentTool = mutableCell(defaultTool)
currentTool = _currentTool
toolToActive = tools.associateWith { tool -> currentTool eq tool }
addDisposables(
window.disposableListener("keydown", ::dispatchGlobalKeyDown),
)
observeNow(applicationUrl.url) { setDataFromUrl(it) }
}
fun setCurrentTool(tool: PwToolType) {
if (tool != currentTool.value) {
updateApplicationUrl(tool, path = "", replace = false)
setCurrentTool(tool, path = "")
}
navigationStore.setCurrentTool(tool)
}
/**
* Updates [path] to [prefix] if the current path doesn't start with [prefix].
*/
fun setPathPrefix(prefix: String, replace: Boolean) {
if (!path.value.startsWith(prefix)) {
updateApplicationUrl(currentTool.value, prefix, replace)
_path.value = prefix
}
navigationStore.setPathPrefix(prefix, replace)
}
fun registerParameter(
tool: PwToolType,
path: String,
parameter: String,
setInitialValue: (String?) -> Unit,
value: Cell<String?>,
onChange: (String?) -> Unit,
): Disposable {
require(parameter !== FEATURES_PARAM) {
"$FEATURES_PARAM can't be set because it is a global parameter."
}
val pathParams = parameters.getOrPut("/${tool.slug}$path", ::mutableMapOf)
val param = pathParams.getOrPut(parameter) { mutableCell(null) }
setInitialValue(param.value)
value.value.let { v ->
if (v != param.value) {
setParameter(tool, path, param, v, replaceUrl = true)
}
}
return Disposer(
value.observeChange {
if (it.value != param.value) {
setParameter(tool, path, param, it.value, replaceUrl = false)
}
},
param.observeChange { onChange(it.value) },
)
}
private fun setParameter(
tool: PwToolType,
path: String,
parameter: MutableCell<String?>,
value: String?,
replaceUrl: Boolean,
) {
parameter.value = value
if (this.currentTool.value == tool && this.path.value == path) {
updateApplicationUrl(tool, path, replaceUrl)
}
}
): Param =
navigationStore.registerParameter(tool, path, parameter, onChange)
fun onGlobalKeyDown(
tool: PwToolType,
@ -164,75 +110,6 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
return disposable { globalKeyDownHandlers.remove(key) }
}
/**
* Sets [currentTool], [path], [parameters] and [features].
*/
private fun setDataFromUrl(url: String) {
val urlSplit = url.split("?")
val fullPath = urlSplit[0]
val paramsStr = urlSplit.getOrNull(1)
val secondSlashIdx = fullPath.indexOf("/", 1)
val toolStr =
if (secondSlashIdx == -1) fullPath.substring(1)
else fullPath.substring(1, secondSlashIdx)
val tool = SLUG_TO_PW_TOOL[toolStr]
val path = if (secondSlashIdx == -1) "" else fullPath.substring(secondSlashIdx)
if (paramsStr != null) {
val params = parameters.getOrPut(fullPath, ::mutableMapOf)
for (p in paramsStr.split("&")) {
val (param, value) = p.split("=", limit = 2)
if (param == FEATURES_PARAM) {
for (feature in value.split(",")) {
features.add(feature)
}
} else {
params.getOrPut(param) { mutableCell(value) }.value = value
}
}
}
val actualTool = tool ?: defaultTool
this.setCurrentTool(actualTool, path)
if (tool == null) {
updateApplicationUrl(actualTool, path, replace = true)
}
}
private fun setCurrentTool(tool: PwToolType, path: String) {
_path.value = path
_currentTool.value = tool
}
private fun updateApplicationUrl(tool: PwToolType, path: String, replace: Boolean) {
val fullPath = "/${tool.slug}${path}"
val params = mutableMapOf<String, String>()
parameters[fullPath]?.forEach { (k, v) ->
v.value?.let { params[k] = it }
}
if (features.isNotEmpty()) {
params[FEATURES_PARAM] = features.joinToString(",")
}
val paramStr =
if (params.isEmpty()) ""
else "?" + params.map { (k, v) -> "$k=$v" }.joinToString("&")
val url = "${fullPath}${paramStr}"
if (replace) {
applicationUrl.replaceUrl(url)
} else {
applicationUrl.pushUrl(url)
}
}
private fun dispatchGlobalKeyDown(e: KeyboardEvent) {
val bindingParts = mutableListOf<String>()
@ -255,9 +132,226 @@ class UiStore(private val applicationUrl: ApplicationUrl) : Store() {
return "$tool -> $binding"
}
companion object {
/**
* The default tool that's loaded if the initial URL has no specific path.
*/
val DEFAULT_TOOL: PwToolType = PwToolType.Viewer
}
}
/**
* Deconstructs the URL into [currentTool], [path], [parameters] and [features], propagates changes
* to the URL (e.g. when the user navigates backward or forward through the browser history) to the
* rest of the application and allows the application to update the URL.
*/
class NavigationStore(private val applicationUrl: ApplicationUrl) : DisposableContainer() {
private val _currentTool = mutableCell(UiStore.DEFAULT_TOOL)
/**
* The tool that is currently visible.
*/
val currentTool: Cell<PwToolType> = _currentTool
private val _path = mutableCell("")
/**
* Application URL without the tool path prefix.
* E.g. when the full path is `/viewer/models`, [path] will be `/models`.
*/
val path: Cell<String> = _path
/**
* Maps full paths to maps of parameters and their values. In other words we keep track of
* parameter values per full path.
*/
private val parameters: MutableMap<String, MutableMap<String, ParamValue>> =
mutableMapOf()
/**
* Enabled alpha features. Alpha features can be turned on by adding a features parameter with
* the comma-separated feature names as value.
* E.g. `/viewer?features=f1,f2,f3` to enable features f1, f2 and f3.
*/
private val features: MutableSet<String> = mutableSetOf()
init {
deconstructPathAndParams(applicationUrl.pathAndParams)
addDisposable(applicationUrl.onPopPathAndParams(::deconstructPathAndParams))
}
fun setCurrentTool(tool: PwToolType) {
if (tool != currentTool.value) {
updateApplicationUrl(tool, path = "", replace = false)
setCurrentTool(tool, path = "")
}
}
private fun setCurrentTool(tool: PwToolType, path: String) {
_path.value = path
_currentTool.value = tool
}
/**
* Updates [path] to [prefix] if the current path doesn't start with [prefix].
*/
fun setPathPrefix(prefix: String, replace: Boolean) {
if (!path.value.startsWith(prefix)) {
updateApplicationUrl(currentTool.value, prefix, replace)
_path.value = prefix
}
}
fun registerParameter(
tool: PwToolType,
path: String,
parameter: String,
onChange: (String?) -> Unit,
): Param {
require(parameter !== FEATURES_PARAM) {
"$FEATURES_PARAM can't be set because it is a global parameter."
}
val pathParams = parameters.getOrPut("/${tool.slug}$path", ::mutableMapOf)
val paramCtx =
pathParams.getOrPut(parameter) { ParamValue(value = null, onChange = null) }
require(paramCtx.onChange == null) {
"Parameter $parameter is already registered."
}
return ParamImpl(paramCtx, tool, path, onChange)
}
/**
* Sets [currentTool], [path], [parameters] and [features].
*/
private fun deconstructPathAndParams(url: String) {
val urlSplit = url.split("?")
val fullPath = urlSplit[0]
val paramsStr = urlSplit.getOrNull(1)
val secondSlashIdx = fullPath.indexOf("/", 1)
val toolStr =
if (secondSlashIdx == -1) fullPath.substring(1)
else fullPath.substring(1, secondSlashIdx)
val tool = SLUG_TO_PW_TOOL[toolStr]
val path = if (secondSlashIdx == -1) "" else fullPath.substring(secondSlashIdx)
if (paramsStr != null) {
val params = parameters.getOrPut(fullPath, ::mutableMapOf)
for (paramNameAndValue in paramsStr.split("&")) {
val paramNameAndValueSplit = paramNameAndValue.split("=", limit = 2)
val paramName = paramNameAndValueSplit[0]
val value = paramNameAndValueSplit.getOrNull(1)
if (paramName == FEATURES_PARAM) {
if (value != null) {
for (feature in value.split(",")) {
features.add(feature)
}
}
} else {
val param = params[paramName]
if (param == null) {
params[paramName] = ParamValue(value, onChange = null)
} else {
param.updateAndCallOnChange(value)
}
}
}
}
val actualTool = tool ?: UiStore.DEFAULT_TOOL
this.setCurrentTool(actualTool, path)
if (tool == null) {
updateApplicationUrl(actualTool, path, replace = true)
}
}
// TODO: Use buildString.
private fun updateApplicationUrl(tool: PwToolType, path: String, replace: Boolean) {
val fullPath = "/${tool.slug}${path}"
val params = mutableMapOf<String, String>()
parameters[fullPath]?.forEach { (k, v) ->
v.value?.let { params[k] = it }
}
if (features.isNotEmpty()) {
params[FEATURES_PARAM] = features.joinToString(",")
}
val paramStr =
if (params.isEmpty()) ""
else "?" + params.map { (k, v) -> "$k=$v" }.joinToString("&")
val url = "${fullPath}${paramStr}"
if (replace) {
applicationUrl.replacePathAndParams(url)
} else {
applicationUrl.pushPathAndParams(url)
}
}
companion object {
private const val FEATURES_PARAM = "features"
private val SLUG_TO_PW_TOOL: Map<String, PwToolType> =
PwToolType.values().associateBy { it.slug }
}
private class ParamValue(
value: String?,
var onChange: ((String?) -> Unit)?,
) {
var value: String? = value
private set
fun update(value: String?): Boolean {
val changed = value != this.value
this.value = value
return changed
}
fun updateAndCallOnChange(value: String?) {
if (update(value)) {
onChange?.invoke(value)
}
}
}
private inner class ParamImpl(
private val paramValue: ParamValue,
private val tool: PwToolType,
private val paramPath: String,
onChange: (String?) -> Unit,
) : TrackedDisposable(), Param {
override val value: String? get() = paramValue.value
init {
require(paramValue.onChange == null)
paramValue.onChange = onChange
}
override fun set(value: String?) {
// Always update parameter value.
if (paramValue.update(value)) {
// Only update URL if current part of the tool is visible.
if (currentTool.value == tool && path.value == paramPath) {
updateApplicationUrl(tool, paramPath, replace = true)
}
}
}
override fun dispose() {
paramValue.onChange = null
super.dispose()
}
}
}

View File

@ -1,21 +1,21 @@
package world.phantasmal.web.core.undo
import world.phantasmal.observable.cell.Cell
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
interface Undo {
val canUndo: Cell<Boolean>
val canRedo: Cell<Boolean>
/**
* The first action that will be undone when calling undo().
* The first command that will be undone when calling undo().
*/
val firstUndo: Cell<Action?>
val firstUndo: Cell<Command?>
/**
* The first action that will be redone when calling redo().
* The first command that will be redone when calling redo().
*/
val firstRedo: Cell<Action?>
val firstRedo: Cell<Command?>
/**
* True if this undo is at the point in time where the last save happened. See [savePoint].

View File

@ -3,7 +3,7 @@ package world.phantasmal.web.core.undo
import world.phantasmal.observable.cell.*
import world.phantasmal.observable.cell.list.fold
import world.phantasmal.observable.cell.list.mutableListCell
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
class UndoManager {
private val undos = mutableListCell<Undo>(NopUndo)
@ -13,8 +13,8 @@ class UndoManager {
val canUndo: Cell<Boolean> = current.flatMap { it.canUndo }
val canRedo: Cell<Boolean> = current.flatMap { it.canRedo }
val firstUndo: Cell<Action?> = current.flatMap { it.firstUndo }
val firstRedo: Cell<Action?> = current.flatMap { it.firstRedo }
val firstUndo: Cell<Command?> = current.flatMap { it.firstUndo }
val firstRedo: Cell<Command?> = current.flatMap { it.firstRedo }
/**
* True if all undos are at the most recent save point. I.e., true if there are no changes to

View File

@ -2,17 +2,17 @@ package world.phantasmal.web.core.undo
import world.phantasmal.observable.cell.*
import world.phantasmal.observable.cell.list.mutableListCell
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
/**
* Full-fledged linear undo/redo implementation.
*/
class UndoStack(manager: UndoManager) : Undo {
private val stack = mutableListCell<Action>()
private val stack = mutableListCell<Command>()
/**
* The index where new actions are inserted. If not equal to the [stack]'s size, points to the
* action that will be redone when calling [redo].
* The index where new commands are inserted. If not equal to the [stack]'s size, points to the
* command that will be redone when calling [redo].
*/
private val index = mutableCell(0)
private val savePointIndex = mutableCell(0)
@ -22,9 +22,9 @@ class UndoStack(manager: UndoManager) : Undo {
override val canRedo: Cell<Boolean> = map(stack, index) { stack, index -> index < stack.size }
override val firstUndo: Cell<Action?> = index.map { stack.value.getOrNull(it - 1) }
override val firstUndo: Cell<Command?> = index.map { stack.value.getOrNull(it - 1) }
override val firstRedo: Cell<Action?> = index.map { stack.value.getOrNull(it) }
override val firstRedo: Cell<Command?> = index.map { stack.value.getOrNull(it) }
override val atSavePoint: Cell<Boolean> = index eq savePointIndex
@ -32,13 +32,13 @@ class UndoStack(manager: UndoManager) : Undo {
manager.addUndo(this)
}
fun push(action: Action): Action {
fun push(command: Command): Command {
if (!undoingOrRedoing) {
stack.splice(index.value, stack.value.size - index.value, action)
stack.splice(index.value, stack.value.size - index.value, command)
index.value++
}
return action
return command
}
override fun undo(): Boolean {

View File

@ -48,7 +48,7 @@ class HuntMethodStore(
val server = uiStore.server.value
withContext(Dispatchers.Default) {
val quests = assetLoader.load<List<QuestDto>>("/quests.${server.slug}.json")
val quests: List<QuestDto> = assetLoader.load("/quests.${server.slug}.json")
val methods = quests
.asSequence()
@ -84,7 +84,7 @@ class HuntMethodStore(
}
val duration = when {
quest.name.matches(Regex("""^\d-\d.*""")) ->
quest.name.matches(GOVERNMENT_QUEST_NAME_REGEX) ->
DEFAULT_GOVERNMENT_TEST_DURATION
totalEnemyCount > 400 ->
@ -117,6 +117,7 @@ class HuntMethodStore(
}
companion object {
private val GOVERNMENT_QUEST_NAME_REGEX = Regex("""^\d-\d.*""")
private val DEFAULT_DURATION = Duration.minutes(30)
private val DEFAULT_GOVERNMENT_TEST_DURATION = Duration.minutes(45)
private val DEFAULT_LARGE_ENEMY_COUNT_DURATION = Duration.minutes(45)

View File

@ -1,25 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestModel
class CreateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val quest: QuestModel,
private val entity: QuestEntityModel<*, *>,
) : Action {
override val description: String = "Add ${entity.type.name}"
override fun execute() {
change {
quest.addEntity(entity)
setSelectedEntity(entity)
}
}
override fun undo() {
quest.removeEntity(entity)
}
}

View File

@ -1,29 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.models.QuestModel
class CreateEventAction(
private val setSelectedEvent: (QuestEventModel?) -> Unit,
private val quest: QuestModel,
private val index: Int,
private val event: QuestEventModel,
) : Action {
override val description: String = "Add event"
override fun execute() {
change {
quest.addEvent(index, event)
setSelectedEvent(event)
}
}
override fun undo() {
change {
setSelectedEvent(null)
quest.removeEvent(event)
}
}
}

View File

@ -1,32 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel
/**
* Creates a quest event action.
*/
class CreateEventActionAction(
private val setSelectedEvent: (QuestEventModel) -> Unit,
private val event: QuestEventModel,
private val action: QuestEventActionModel,
) : Action {
override val description: String =
"Add ${action.shortName} action to event ${event.id.value}"
override fun execute() {
change {
event.addAction(action)
setSelectedEvent(event)
}
}
override fun undo() {
change {
event.removeAction(action)
setSelectedEvent(event)
}
}
}

View File

@ -1,25 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestModel
class DeleteEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val quest: QuestModel,
private val entity: QuestEntityModel<*, *>,
) : Action {
override val description: String = "Delete ${entity.type.name}"
override fun execute() {
quest.removeEntity(entity)
}
override fun undo() {
change {
quest.addEntity(entity)
setSelectedEntity(entity)
}
}
}

View File

@ -1,29 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.models.QuestModel
class DeleteEventAction(
private val setSelectedEvent: (QuestEventModel?) -> Unit,
private val quest: QuestModel,
private val index: Int,
private val event: QuestEventModel,
) : Action {
override val description: String = "Delete event ${event.id.value}"
override fun execute() {
change {
setSelectedEvent(null)
quest.removeEvent(event)
}
}
override fun undo() {
change {
quest.addEvent(index, event)
setSelectedEvent(event)
}
}
}

View File

@ -1,30 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestEntityPropModel
class EditEntityPropAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val entity: QuestEntityModel<*, *>,
private val prop: QuestEntityPropModel,
private val newValue: Any,
private val oldValue: Any,
) : Action {
override val description: String = "Edit ${entity.type.simpleName} ${prop.name}"
override fun execute() {
change {
setSelectedEntity(entity)
prop.setValue(newValue)
}
}
override fun undo() {
change {
setSelectedEntity(entity)
prop.setValue(oldValue)
}
}
}

View File

@ -1,28 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEventModel
class EditEventPropertyAction<T>(
override val description: String,
private val setSelectedEvent: (QuestEventModel) -> Unit,
private val event: QuestEventModel,
private val setter: (T) -> Unit,
private val newValue: T,
private val oldValue: T,
) : Action {
override fun execute() {
change {
setSelectedEvent(event)
setter(newValue)
}
}
override fun undo() {
change {
setSelectedEvent(event)
setter(oldValue)
}
}
}

View File

@ -1,18 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.web.core.actions.Action
class EditPropertyAction<T>(
override val description: String,
private val setter: (T) -> Unit,
private val newValue: T,
private val oldValue: T,
) : Action {
override fun execute() {
setter(newValue)
}
override fun undo() {
setter(oldValue)
}
}

View File

@ -1,40 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.questEditor.models.QuestEntityModel
class RotateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val entity: QuestEntityModel<*, *>,
private val newRotation: Euler,
private val oldRotation: Euler,
private val world: Boolean,
) : Action {
override val description: String = "Rotate ${entity.type.simpleName}"
override fun execute() {
change {
setSelectedEntity(entity)
if (world) {
entity.setWorldRotation(newRotation)
} else {
entity.setRotation(newRotation)
}
}
}
override fun undo() {
change {
setSelectedEntity(entity)
if (world) {
entity.setWorldRotation(oldRotation)
} else {
entity.setRotation(oldRotation)
}
}
}
}

View File

@ -1,38 +0,0 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel
class TranslateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val setEntitySection: (Int) -> Unit,
private val entity: QuestEntityModel<*, *>,
private val newSection: Int?,
private val oldSection: Int?,
private val newPosition: Vector3,
private val oldPosition: Vector3,
) : Action {
override val description: String = "Move ${entity.type.simpleName}"
override fun execute() {
change {
setSelectedEntity(entity)
newSection?.let(setEntitySection)
entity.setPosition(newPosition)
}
}
override fun undo() {
change {
setSelectedEntity(entity)
oldSection?.let(setEntitySection)
entity.setPosition(oldPosition)
}
}
}

View File

@ -0,0 +1,22 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class CreateEntityCommand(
private val questEditorStore: QuestEditorStore,
private val quest: QuestModel,
private val entity: QuestEntityModel<*, *>,
) : Command {
override val description: String = "Add ${entity.type.name}"
override fun execute() {
questEditorStore.addEntity(quest, entity)
}
override fun undo() {
questEditorStore.removeEntity(quest, entity)
}
}

View File

@ -0,0 +1,26 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
/**
* Creates a quest event action.
*/
class CreateEventActionCommand(
private val questEditorStore: QuestEditorStore,
private val event: QuestEventModel,
private val action: QuestEventActionModel,
) : Command {
override val description: String =
"Add ${action.shortName} action to event ${event.id.value}"
override fun execute() {
questEditorStore.addEventAction(event, action)
}
override fun undo() {
questEditorStore.removeEventAction(event, action)
}
}

View File

@ -0,0 +1,23 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class CreateEventCommand(
private val questEditorStore: QuestEditorStore,
private val quest: QuestModel,
private val index: Int,
private val event: QuestEventModel,
) : Command {
override val description: String = "Add event"
override fun execute() {
questEditorStore.addEvent(quest, index, event)
}
override fun undo() {
questEditorStore.removeEvent(quest, event)
}
}

View File

@ -0,0 +1,22 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class DeleteEntityCommand(
private val questEditorStore: QuestEditorStore,
private val quest: QuestModel,
private val entity: QuestEntityModel<*, *>,
) : Command {
override val description: String = "Delete ${entity.type.name}"
override fun execute() {
questEditorStore.removeEntity(quest, entity)
}
override fun undo() {
questEditorStore.addEntity(quest, entity)
}
}

View File

@ -1,33 +1,27 @@
package world.phantasmal.web.questEditor.actions
package world.phantasmal.web.questEditor.commands
import world.phantasmal.observable.change
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
/**
* Deletes a quest event action.
*/
class DeleteEventActionAction(
private val setSelectedEvent: (QuestEventModel) -> Unit,
class DeleteEventActionCommand(
private val questEditorStore: QuestEditorStore,
private val event: QuestEventModel,
private val index: Int,
private val action: QuestEventActionModel,
) : Action {
) : Command {
override val description: String =
"Remove ${action.shortName} action from event ${event.id.value}"
override fun execute() {
change {
setSelectedEvent(event)
event.removeAction(action)
}
questEditorStore.removeEventAction(event, action)
}
override fun undo() {
change {
setSelectedEvent(event)
event.addAction(index, action)
}
questEditorStore.addEventAction(event, index, action)
}
}

View File

@ -0,0 +1,23 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class DeleteEventCommand(
private val questEditorStore: QuestEditorStore,
private val quest: QuestModel,
private val index: Int,
private val event: QuestEventModel,
) : Command {
override val description: String = "Delete event ${event.id.value}"
override fun execute() {
questEditorStore.removeEvent(quest, event)
}
override fun undo() {
questEditorStore.addEvent(quest, index, event)
}
}

View File

@ -0,0 +1,27 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestEntityPropModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
/**
* Edits a dynamic entity property.
*/
class EditEntityPropCommand(
private val questEditorStore: QuestEditorStore,
private val entity: QuestEntityModel<*, *>,
private val prop: QuestEntityPropModel,
private val newValue: Any,
private val oldValue: Any,
) : Command {
override val description: String = "Edit ${entity.type.simpleName} ${prop.name}"
override fun execute() {
questEditorStore.setEntityProp(entity, prop, newValue)
}
override fun undo() {
questEditorStore.setEntityProp(entity, prop, oldValue)
}
}

View File

@ -0,0 +1,25 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
/**
* Edits a simple entity property.
*/
class EditEntityPropertyCommand<Entity : QuestEntityModel<*, *>, T>(
private val questEditorStore: QuestEditorStore,
override val description: String,
private val entity: Entity,
private val setter: (Entity, T) -> Unit,
private val newValue: T,
private val oldValue: T,
) : Command {
override fun execute() {
questEditorStore.setEntityProperty(entity, setter, newValue)
}
override fun undo() {
questEditorStore.setEntityProperty(entity, setter, oldValue)
}
}

View File

@ -1,16 +1,18 @@
package world.phantasmal.web.questEditor.actions
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class EditEntitySectionAction(
class EditEntitySectionCommand(
private val questEditorStore: QuestEditorStore,
private val entity: QuestEntityModel<*, *>,
private val newSectionId: Int,
private val newSection: SectionModel?,
private val oldSectionId: Int,
private val oldSection: SectionModel?,
) : Action {
) : Command {
override val description: String = "Edit ${entity.type.simpleName} section"
init {
@ -20,17 +22,17 @@ class EditEntitySectionAction(
override fun execute() {
if (newSection != null) {
entity.setSection(newSection)
questEditorStore.setEntitySection(entity, newSection)
} else {
entity.setSectionId(newSectionId)
questEditorStore.setEntitySectionId(entity, newSectionId)
}
}
override fun undo() {
if (oldSection != null) {
entity.setSection(oldSection)
questEditorStore.setEntitySection(entity, oldSection)
} else {
entity.setSectionId(oldSectionId)
questEditorStore.setEntitySectionId(entity, oldSectionId)
}
}
}

View File

@ -0,0 +1,24 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class EditEventActionPropertyCommand<EventAction : QuestEventActionModel, T>(
private val questEditorStore: QuestEditorStore,
override val description: String,
private val event: QuestEventModel,
private val action: EventAction,
private val setter: (EventAction, T) -> Unit,
private val newValue: T,
private val oldValue: T,
) : Command {
override fun execute() {
questEditorStore.setEventActionProperty(event, action, setter, newValue)
}
override fun undo() {
questEditorStore.setEventActionProperty(event, action, setter, oldValue)
}
}

View File

@ -0,0 +1,22 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class EditEventPropertyCommand<T>(
private val questEditorStore: QuestEditorStore,
override val description: String,
private val event: QuestEventModel,
private val setter: (QuestEventModel, T) -> Unit,
private val newValue: T,
private val oldValue: T,
) : Command {
override fun execute() {
questEditorStore.setEventProperty(event, setter, newValue)
}
override fun undo() {
questEditorStore.setEventProperty(event, setter, oldValue)
}
}

View File

@ -0,0 +1,22 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class EditQuestPropertyCommand<T>(
private val questEditorStore: QuestEditorStore,
override val description: String,
private val quest: QuestModel,
private val setter: (QuestModel, T) -> Unit,
private val newValue: T,
private val oldValue: T,
) : Command {
override fun execute() {
questEditorStore.setQuestProperty(quest, setter, newValue)
}
override fun undo() {
questEditorStore.setQuestProperty(quest, setter, oldValue)
}
}

View File

@ -0,0 +1,32 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class RotateEntityCommand(
private val questEditorStore: QuestEditorStore,
private val entity: QuestEntityModel<*, *>,
private val newRotation: Euler,
private val oldRotation: Euler,
private val world: Boolean,
) : Command {
override val description: String = "Rotate ${entity.type.simpleName}"
override fun execute() {
if (world) {
questEditorStore.setEntityWorldRotation(entity, newRotation)
} else {
questEditorStore.setEntityRotation(entity, newRotation)
}
}
override fun undo() {
if (world) {
questEditorStore.setEntityWorldRotation(entity, oldRotation)
} else {
questEditorStore.setEntityRotation(entity, oldRotation)
}
}
}

View File

@ -0,0 +1,25 @@
package world.phantasmal.web.questEditor.commands
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class TranslateEntityCommand(
private val questEditorStore: QuestEditorStore,
private val entity: QuestEntityModel<*, *>,
private val newSection: Int?,
private val oldSection: Int?,
private val newPosition: Vector3,
private val oldPosition: Vector3,
) : Command {
override val description: String = "Move ${entity.type.simpleName}"
override fun execute() {
questEditorStore.setEntityPosition(entity, newSection, newPosition)
}
override fun undo() {
questEditorStore.setEntityPosition(entity, oldSection, oldPosition)
}
}

View File

@ -11,7 +11,7 @@ import world.phantasmal.psolib.fileFormats.quest.EntityPropType
import world.phantasmal.web.core.euler
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.actions.*
import world.phantasmal.web.questEditor.commands.*
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestEntityPropModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
@ -28,8 +28,8 @@ sealed class EntityInfoPropModel(
protected fun setPropValue(prop: QuestEntityPropModel, value: Any) {
store.selectedEntity.value?.let { entity ->
store.executeAction(
EditEntityPropAction(
setSelectedEntity = store::setSelectedEntity,
EditEntityPropCommand(
store,
entity,
prop,
value,
@ -142,7 +142,8 @@ class EntityInfoController(
sectionId,
)
questEditorStore.executeAction(
EditEntitySectionAction(
EditEntitySectionCommand(
questEditorStore,
entity,
sectionId,
section,
@ -157,9 +158,11 @@ class EntityInfoController(
fun setWaveId(waveId: Int) {
(questEditorStore.selectedEntity.value as? QuestNpcModel)?.let { npc ->
questEditorStore.executeAction(
EditPropertyAction(
EditEntityPropertyCommand(
questEditorStore,
"Edit ${npc.type.simpleName} wave",
npc::setWaveId,
npc,
QuestNpcModel::setWaveId,
waveId,
npc.wave.value.id,
)
@ -192,9 +195,8 @@ class EntityInfoController(
if (!enabled.value) return
questEditorStore.executeAction(
TranslateEntityAction(
setSelectedEntity = questEditorStore::setSelectedEntity,
setEntitySection = { /* Won't be called. */ },
TranslateEntityCommand(
questEditorStore,
entity,
newSection = null,
oldSection = null,
@ -229,12 +231,12 @@ class EntityInfoController(
if (!enabled.value) return
questEditorStore.executeAction(
RotateEntityAction(
setSelectedEntity = questEditorStore::setSelectedEntity,
RotateEntityCommand(
questEditorStore,
entity,
euler(x, y, z),
entity.rotation.value,
false,
world = false,
)
)
}

View File

@ -3,7 +3,7 @@ package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.cell.*
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.listCell
import world.phantasmal.web.questEditor.actions.*
import world.phantasmal.web.questEditor.commands.*
import world.phantasmal.web.questEditor.models.QuestEventActionModel
import world.phantasmal.web.questEditor.models.QuestEventModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
@ -48,8 +48,8 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
else quest.events.value.indexOf(selectedEvent) + 1
store.executeAction(
CreateEventAction(
::selectEvent,
CreateEventCommand(
store,
quest,
index,
QuestEventModel(
@ -78,7 +78,7 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
if (index != -1) {
store.executeAction(
DeleteEventAction(::selectEvent, quest, index, event)
DeleteEventCommand(store, quest, index, event)
)
}
}
@ -86,11 +86,11 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
fun setId(event: QuestEventModel, id: Int) {
store.executeAction(
EditEventPropertyAction(
EditEventPropertyCommand(
store,
"Edit ID of event ${event.id.value}",
::selectEvent,
event,
event::setId,
QuestEventModel::setId,
id,
event.id.value,
)
@ -99,11 +99,11 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
fun setSectionId(event: QuestEventModel, sectionId: Int) {
store.executeAction(
EditEventPropertyAction(
EditEventPropertyCommand(
store,
"Edit section of event ${event.id.value}",
::selectEvent,
event,
event::setSectionId,
QuestEventModel::setSectionId,
sectionId,
event.sectionId.value,
)
@ -112,11 +112,11 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
fun setWaveId(event: QuestEventModel, waveId: Int) {
store.executeAction(
EditEventPropertyAction(
EditEventPropertyCommand(
store,
"Edit wave of event ${event.id}",
::selectEvent,
event,
event::setWaveId,
QuestEventModel::setWaveId,
waveId,
event.wave.value.id,
)
@ -125,11 +125,11 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
fun setDelay(event: QuestEventModel, delay: Int) {
store.executeAction(
EditEventPropertyAction(
EditEventPropertyCommand(
store,
"Edit delay of event ${event.id}",
::selectEvent,
event,
event::setDelay,
QuestEventModel::setDelay,
delay,
event.delay.value,
)
@ -145,12 +145,12 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
else -> error("""Unknown action type "$type".""")
}
store.executeAction(CreateEventActionAction(::selectEvent, event, action))
store.executeAction(CreateEventActionCommand(store, event, action))
}
fun removeAction(event: QuestEventModel, action: QuestEventActionModel) {
val index = event.actions.value.indexOf(action)
store.executeAction(DeleteEventActionAction(::selectEvent, event, index, action))
store.executeAction(DeleteEventActionCommand(store, event, index, action))
}
fun canGoToEvent(eventId: Cell<Int>): Cell<Boolean> = store.canGoToEvent(eventId)
@ -165,11 +165,11 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
sectionId: Int,
) {
store.executeAction(
EditEventPropertyAction(
EditEventPropertyCommand(
store,
"Edit action section",
::selectEvent,
event,
action::setSectionId,
QuestEventModel::setSectionId,
sectionId,
action.sectionId.value,
)
@ -182,11 +182,12 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
appearFlag: Int,
) {
store.executeAction(
EditEventPropertyAction(
EditEventActionPropertyCommand(
store,
"Edit action appear flag",
::selectEvent,
event,
action::setAppearFlag,
action,
QuestEventActionModel.SpawnNpcs::setAppearFlag,
appearFlag,
action.appearFlag.value,
)
@ -199,11 +200,12 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
doorId: Int,
) {
store.executeAction(
EditEventPropertyAction(
EditEventActionPropertyCommand(
store,
"Edit action door",
::selectEvent,
event,
action::setDoorId,
action,
QuestEventActionModel.Door::setDoorId,
doorId,
action.doorId.value,
)
@ -216,11 +218,12 @@ class EventsController(private val store: QuestEditorStore) : Controller() {
eventId: Int,
) {
store.executeAction(
EditEventPropertyAction(
EditEventActionPropertyCommand(
store,
"Edit action event",
::selectEvent,
event,
action::setEventId,
action,
QuestEventActionModel.TriggerEvent::setEventId,
eventId,
action.eventId.value,
)

View File

@ -3,10 +3,10 @@ package world.phantasmal.web.questEditor.controllers
import kotlinx.coroutines.await
import mu.KotlinLogging
import world.phantasmal.core.*
import world.phantasmal.observable.cell.*
import world.phantasmal.psolib.Endianness
import world.phantasmal.psolib.Episode
import world.phantasmal.psolib.fileFormats.quest.*
import world.phantasmal.observable.cell.*
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.files.cursor
import world.phantasmal.web.core.files.writeBuffer
@ -74,16 +74,17 @@ class QuestEditorToolbarController(
// Undo
val undoTooltip: Cell<String> = questEditorStore.firstUndo.map { action ->
(action?.let { "Undo \"${action.description}\"" } ?: "Nothing to undo") + " (Ctrl-Z)"
val undoTooltip: Cell<String> = questEditorStore.firstUndo.map { command ->
(command?.let { "Undo \"${command.description}\"" } ?: "Nothing to undo") + " (Ctrl-Z)"
}
val undoEnabled: Cell<Boolean> = questEditorStore.canUndo
// Redo
val redoTooltip: Cell<String> = questEditorStore.firstRedo.map { action ->
(action?.let { "Redo \"${action.description}\"" } ?: "Nothing to redo") + " (Ctrl-Shift-Z)"
val redoTooltip: Cell<String> = questEditorStore.firstRedo.map { command ->
(command?.let { "Redo \"${command.description}\"" } ?: "Nothing to redo") +
" (Ctrl-Shift-Z)"
}
val redoEnabled: Cell<Boolean> = questEditorStore.canRedo

View File

@ -1,7 +1,8 @@
package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.cell.*
import world.phantasmal.web.questEditor.actions.EditPropertyAction
import world.phantasmal.web.questEditor.commands.EditQuestPropertyCommand
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller
@ -25,7 +26,14 @@ class QuestInfoController(private val store: QuestEditorStore) : Controller() {
if (!enabled.value) return
store.currentQuest.value?.let { quest ->
store.executeAction(EditPropertyAction("Edit ID", quest::setId, id, quest.id.value))
store.executeAction(EditQuestPropertyCommand(
store,
"Edit ID",
quest,
QuestModel::setId,
id,
quest.id.value,
))
}
}
@ -34,7 +42,14 @@ class QuestInfoController(private val store: QuestEditorStore) : Controller() {
store.currentQuest.value?.let { quest ->
store.executeAction(
EditPropertyAction("Edit name", quest::setName, name, quest.name.value)
EditQuestPropertyCommand(
store,
"Edit name",
quest,
QuestModel::setName,
name,
quest.name.value,
)
)
}
}
@ -44,9 +59,11 @@ class QuestInfoController(private val store: QuestEditorStore) : Controller() {
store.currentQuest.value?.let { quest ->
store.executeAction(
EditPropertyAction(
EditQuestPropertyCommand(
store,
"Edit short description",
quest::setShortDescription,
quest,
QuestModel::setShortDescription,
shortDescription,
quest.shortDescription.value,
)
@ -59,9 +76,11 @@ class QuestInfoController(private val store: QuestEditorStore) : Controller() {
store.currentQuest.value?.let { quest ->
store.executeAction(
EditPropertyAction(
EditQuestPropertyCommand(
store,
"Edit long description",
quest::setLongDescription,
quest,
QuestModel::setLongDescription,
longDescription,
quest.longDescription.value,
)

View File

@ -84,7 +84,6 @@ class QuestInputManager(
stateContext = StateContext(questEditorStore, renderContext, cameraInputManager)
state = IdleState(stateContext, entityManipulationEnabled)
observeNow(questEditorStore.selectedEntity) { returnToIdleState() }
observeNow(questEditorStore.questEditingEnabled) { entityManipulationEnabled = it }
pointerTrap.className = "pw-quest-editor-input-manager-pointer-trap"
@ -176,6 +175,7 @@ class QuestInputManager(
renderContext.canvas.disposableListener("pointermove", ::onPointerMove)
window.setTimeout({
if (disposed) return@setTimeout
if (!pointerDragging) {
pointerTrap.hidden = true
contextMenuListener?.dispose()

View File

@ -79,7 +79,7 @@ class CreationState(
is EntityDragLeaveEvt -> {
event.showDragElement()
quest.removeEntity(entity)
ctx.removeEntity(quest, entity)
IdleState(ctx, entityManipulationEnabled = true)
}
@ -115,7 +115,7 @@ class CreationState(
}
override fun cancel() {
quest.removeEntity(entity)
ctx.removeEntity(quest, entity)
}
companion object {

View File

@ -35,7 +35,7 @@ class IdleState(
val entity = ctx.selectedEntity.value
if (quest != null && entity != null && event.key == "Delete") {
ctx.deleteEntity(quest, entity)
ctx.finalizeEntityDelete(quest, entity)
}
}
}

View File

@ -11,10 +11,10 @@ import world.phantasmal.web.core.plusAssign
import world.phantasmal.web.core.rendering.OrbitalCameraInputManager
import world.phantasmal.web.core.rendering.conversion.fingerPrint
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.questEditor.actions.CreateEntityAction
import world.phantasmal.web.questEditor.actions.DeleteEntityAction
import world.phantasmal.web.questEditor.actions.RotateEntityAction
import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.commands.CreateEntityCommand
import world.phantasmal.web.questEditor.commands.DeleteEntityCommand
import world.phantasmal.web.questEditor.commands.RotateEntityCommand
import world.phantasmal.web.questEditor.commands.TranslateEntityCommand
import world.phantasmal.web.questEditor.loading.AreaUserData
import world.phantasmal.web.questEditor.models.*
import world.phantasmal.web.questEditor.rendering.QuestRenderContext
@ -128,9 +128,8 @@ class StateContext(
newPosition: Vector3,
oldPosition: Vector3,
) {
questEditorStore.executeAction(TranslateEntityAction(
::setSelectedEntity,
{ questEditorStore.setEntitySection(entity, it) },
questEditorStore.executeAction(TranslateEntityCommand(
questEditorStore,
entity,
newSection?.id,
oldSection?.id,
@ -186,8 +185,8 @@ class StateContext(
newRotation: Euler,
oldRotation: Euler,
) {
questEditorStore.executeAction(RotateEntityAction(
::setSelectedEntity,
questEditorStore.executeAction(RotateEntityCommand(
questEditorStore,
entity,
newRotation,
oldRotation,
@ -196,16 +195,20 @@ class StateContext(
}
fun finalizeEntityCreation(quest: QuestModel, entity: QuestEntityModel<*, *>) {
questEditorStore.pushAction(CreateEntityAction(
::setSelectedEntity,
questEditorStore.pushAction(CreateEntityCommand(
questEditorStore,
quest,
entity,
))
}
fun deleteEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
questEditorStore.executeAction(DeleteEntityAction(
::setSelectedEntity,
fun removeEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
questEditorStore.removeEntity(quest, entity)
}
fun finalizeEntityDelete(quest: QuestModel, entity: QuestEntityModel<*, *>) {
questEditorStore.executeAction(DeleteEntityCommand(
questEditorStore,
quest,
entity,
))

View File

@ -5,12 +5,12 @@ import kotlinx.coroutines.launch
import world.phantasmal.core.Severity
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.disposable
import world.phantasmal.psolib.asm.assemble
import world.phantasmal.psolib.asm.disassemble
import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.psolib.asm.assemble
import world.phantasmal.psolib.asm.disassemble
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.externals.monacoEditor.*
import world.phantasmal.web.questEditor.asm.AsmAnalyser
@ -111,43 +111,48 @@ class AsmStore(
}
private fun setTextModel(quest: QuestModel?, inlineStackArgs: Boolean) {
setBytecodeIrTimeout?.let { it ->
window.clearTimeout(it)
setBytecodeIrTimeout = null
}
modelDisposer.disposeAll()
quest ?: return
val asm = disassemble(quest.bytecodeIr, inlineStackArgs)
asmAnalyser.setAsm(asm, inlineStackArgs)
_textModel.value = createModel(asm.joinToString("\n"), ASM_LANG_ID).also { model ->
modelDisposer.add(disposable { model.dispose() })
model.onDidChangeContent { e ->
asmAnalyser.updateAsm(e.changes.map {
AsmChange(
AsmRange(
it.range.startLineNumber,
it.range.startColumn,
it.range.endLineNumber,
it.range.endColumn,
),
it.text,
)
})
setBytecodeIrTimeout?.let(window::clearTimeout)
setBytecodeIrTimeout = window.setTimeout(::setBytecodeIr, 1000)
// TODO: Update breakpoints.
// TODO: Remove this hack.
window.setTimeout({
setBytecodeIrTimeout?.let { it ->
window.clearTimeout(it)
setBytecodeIrTimeout = null
}
}
modelDisposer.disposeAll()
quest ?: return@setTimeout
val asm = disassemble(quest.bytecodeIr, inlineStackArgs)
asmAnalyser.setAsm(asm, inlineStackArgs)
_textModel.value = createModel(asm.joinToString("\n"), ASM_LANG_ID).also { model ->
modelDisposer.add(disposable { model.dispose() })
model.onDidChangeContent { e ->
asmAnalyser.updateAsm(e.changes.map {
AsmChange(
AsmRange(
it.range.startLineNumber,
it.range.startColumn,
it.range.endLineNumber,
it.range.endColumn,
),
it.text,
)
})
setBytecodeIrTimeout?.let(window::clearTimeout)
setBytecodeIrTimeout = window.setTimeout(::setBytecodeIr, 1000)
// TODO: Update breakpoints.
}
}
}, 0)
}
private fun setBytecodeIr() {
if (disposed) return
setBytecodeIrTimeout = null
val model = textModel.value ?: return

View File

@ -2,17 +2,20 @@ package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.launch
import mu.KotlinLogging
import world.phantasmal.psolib.Episode
import world.phantasmal.observable.cell.*
import world.phantasmal.observable.cell.list.ListCell
import world.phantasmal.observable.cell.list.emptyListCell
import world.phantasmal.observable.cell.list.filtered
import world.phantasmal.observable.cell.list.flatMapToList
import world.phantasmal.observable.change
import world.phantasmal.psolib.Episode
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.core.undo.UndoStack
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.QuestRunner
import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.models.*
@ -74,9 +77,9 @@ class QuestEditorStore(
val questEditingEnabled: Cell<Boolean> = currentQuest.isNotNull() and !runner.running
val canUndo: Cell<Boolean> = questEditingEnabled and undoManager.canUndo
val firstUndo: Cell<Action?> = undoManager.firstUndo
val firstUndo: Cell<Command?> = undoManager.firstUndo
val canRedo: Cell<Boolean> = questEditingEnabled and undoManager.canRedo
val firstRedo: Cell<Action?> = undoManager.firstRedo
val firstRedo: Cell<Command?> = undoManager.firstRedo
/**
* True if there have been changes since the last save.
@ -100,22 +103,6 @@ class QuestEditorStore(
}
}
observeNow(currentQuest.flatMap { it?.npcs ?: emptyListCell() }) { npcs ->
val selected = selectedEntity.value
if (selected is QuestNpcModel && selected !in npcs) {
_selectedEntity.value = null
}
}
observeNow(currentQuest.flatMap { it?.objects ?: emptyListCell() }) { objects ->
val selected = selectedEntity.value
if (selected is QuestObjectModel && selected !in objects) {
_selectedEntity.value = null
}
}
if (initializeNewQuest) {
scope.launch { setCurrentQuest(getDefaultQuest(Episode.I)) }
}
@ -166,6 +153,14 @@ class QuestEditorStore(
suspend fun getDefaultQuest(episode: Episode): QuestModel =
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
fun <T> setQuestProperty(
quest: QuestModel,
setter: (QuestModel, T) -> Unit,
value: T,
) {
setter(quest, value)
}
fun setCurrentArea(area: AreaModel?) {
val event = selectedEvent.value
@ -178,6 +173,20 @@ class QuestEditorStore(
_currentArea.value = area
}
fun addEvent(quest: QuestModel, index: Int, event: QuestEventModel) {
change {
quest.addEvent(index, event)
setSelectedEvent(event)
}
}
fun removeEvent(quest: QuestModel, event: QuestEventModel) {
change {
setSelectedEvent(null)
quest.removeEvent(event)
}
}
fun setSelectedEvent(event: QuestEventModel?) {
event?.let {
val wave = event.wave.value
@ -204,6 +213,50 @@ class QuestEditorStore(
_selectedEvent.value = event
}
fun <T> setEventProperty(
event: QuestEventModel,
setter: (QuestEventModel, T) -> Unit,
value: T,
) {
change {
setSelectedEvent(event)
setter(event, value)
}
}
fun addEventAction(event: QuestEventModel, action: QuestEventActionModel) {
change {
setSelectedEvent(event)
event.addAction(action)
}
}
fun addEventAction(event: QuestEventModel, index: Int, action: QuestEventActionModel) {
change {
setSelectedEvent(event)
event.addAction(index, action)
}
}
fun removeEventAction(event: QuestEventModel, action: QuestEventActionModel) {
change {
setSelectedEvent(event)
event.removeAction(action)
}
}
fun <Action : QuestEventActionModel, T> setEventActionProperty(
event: QuestEventModel,
action: Action,
setter: (Action, T) -> Unit,
value: T,
) {
change {
setSelectedEvent(event)
setter(action, value)
}
}
fun setHighlightedEntity(entity: QuestEntityModel<*, *>?) {
_highlightedEntity.value = entity
}
@ -218,6 +271,63 @@ class QuestEditorStore(
_selectedEntity.value = entity
}
fun addEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
change {
quest.addEntity(entity)
setSelectedEntity(entity)
}
}
fun removeEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
change {
if (entity == _selectedEntity.value) {
_selectedEntity.value = null
}
quest.removeEntity(entity)
}
}
fun setEntityPosition(entity: QuestEntityModel<*, *>, sectionId: Int?, position: Vector3) {
change {
setSelectedEntity(entity)
sectionId?.let { setEntitySection(entity, it) }
entity.setPosition(position)
}
}
fun setEntityRotation(entity: QuestEntityModel<*, *>, rotation: Euler) {
change {
setSelectedEntity(entity)
entity.setRotation(rotation)
}
}
fun setEntityWorldRotation(entity: QuestEntityModel<*, *>, rotation: Euler) {
change {
setSelectedEntity(entity)
entity.setWorldRotation(rotation)
}
}
fun <Entity : QuestEntityModel<*, *>, T> setEntityProperty(
entity: Entity,
setter: (Entity, T) -> Unit,
value: T,
) {
change {
setSelectedEntity(entity)
setter(entity, value)
}
}
fun setEntityProp(entity: QuestEntityModel<*, *>, prop: QuestEntityPropModel, value: Any) {
change {
setSelectedEntity(entity)
prop.setValue(value)
}
}
suspend fun setMapDesignations(mapDesignations: Map<Int, Int>) {
currentQuest.value?.let { quest ->
quest.setMapDesignations(mapDesignations)
@ -225,6 +335,24 @@ class QuestEditorStore(
}
}
fun setEntitySectionId(entity: QuestEntityModel<*, *>, sectionId: Int) {
change {
setSelectedEntity(entity)
entity.setSectionId(sectionId)
}
}
fun setEntitySection(entity: QuestEntityModel<*, *>, section: SectionModel) {
change {
setSelectedEntity(entity)
entity.setSection(section)
}
}
/**
* Sets [QuestEntityModel.sectionId] and [QuestEntityModel.section] if there's a section with
* [sectionId] as ID.
*/
fun setEntitySection(entity: QuestEntityModel<*, *>, sectionId: Int) {
currentQuest.value?.let { quest ->
val variant = quest.areaVariants.value.find { it.area.id == entity.areaId }
@ -242,12 +370,12 @@ class QuestEditorStore(
}
}
fun executeAction(action: Action) {
pushAction(action)
action.execute()
fun executeAction(command: Command) {
pushAction(command)
command.execute()
}
fun pushAction(action: Action) {
fun pushAction(command: Command) {
require(questEditingEnabled.value) {
val reason = when {
currentQuest.value == null -> " (no current quest)"
@ -256,7 +384,7 @@ class QuestEditorStore(
}
"Quest editing is disabled at the moment$reason."
}
mainUndo.push(action)
mainUndo.push(command)
}
fun setShowCollisionGeometry(show: Boolean) {

View File

@ -1,12 +1,13 @@
package world.phantasmal.web.questEditor.undo
import kotlinx.browser.window
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Observable
import world.phantasmal.observable.cell.*
import world.phantasmal.observable.emitter
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.commands.Command
import world.phantasmal.web.core.undo.Undo
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.externals.monacoEditor.IDisposable
@ -17,8 +18,8 @@ class TextModelUndo(
private val description: String,
model: Cell<ITextModel?>,
) : Undo, TrackedDisposable() {
private val action = object : Action {
override val description: String = this@TextModelUndo.description
private val command = object : Command {
override val description: String get() = this@TextModelUndo.description
override fun execute() {
_didRedo.emit(ChangeEvent(Unit))
@ -43,8 +44,8 @@ class TextModelUndo(
override val canUndo: Cell<Boolean> = _canUndo
override val canRedo: Cell<Boolean> = _canRedo
override val firstUndo: Cell<Action?> = canUndo.map { if (it) action else null }
override val firstRedo: Cell<Action?> = canRedo.map { if (it) action else null }
override val firstUndo: Cell<Command?> = canUndo.map { if (it) command else null }
override val firstRedo: Cell<Command?> = canRedo.map { if (it) command else null }
override val atSavePoint: Cell<Boolean> = savePointVersionId eq currentVersionId
@ -63,56 +64,61 @@ class TextModelUndo(
}
private fun onModelChange(model: ITextModel?) {
modelChangeObserver?.dispose()
// TODO: Remove this hack.
window.setTimeout({
if (disposed) return@setTimeout
if (model == null) {
reset()
return
}
modelChangeObserver?.dispose()
_canUndo.value = false
_canRedo.value = false
val initialVersionId = model.getAlternativeVersionId()
currentVersionId.value = initialVersionId
savePointVersionId.value = initialVersionId
var lastVersionId = initialVersionId
modelChangeObserver = model.onDidChangeContent {
val versionId = model.getAlternativeVersionId()
val prevVersionId = currentVersionId.value!!
if (versionId < prevVersionId) {
// Undoing.
_canRedo.value = true
if (versionId == initialVersionId) {
_canUndo.value = false
}
} else {
// Redoing.
if (versionId <= lastVersionId) {
if (versionId == lastVersionId) {
_canRedo.value = false
}
} else {
_canRedo.value = false
if (prevVersionId > lastVersionId) {
lastVersionId = prevVersionId
}
}
_canUndo.value = true
if (model == null) {
reset()
return@setTimeout
}
currentVersionId.value = versionId
}
_canUndo.value = false
_canRedo.value = false
val initialVersionId = model.getAlternativeVersionId()
currentVersionId.value = initialVersionId
savePointVersionId.value = initialVersionId
var lastVersionId = initialVersionId
modelChangeObserver = model.onDidChangeContent {
val versionId = model.getAlternativeVersionId()
val prevVersionId = currentVersionId.value!!
if (versionId < prevVersionId) {
// Undoing.
_canRedo.value = true
if (versionId == initialVersionId) {
_canUndo.value = false
}
} else {
if (versionId <= lastVersionId) {
// Redoing.
if (versionId == lastVersionId) {
_canRedo.value = false
}
} else {
_canRedo.value = false
if (prevVersionId > lastVersionId) {
lastVersionId = prevVersionId
}
}
_canUndo.value = true
}
currentVersionId.value = versionId
}
}, 0)
}
override fun undo(): Boolean =
if (canUndo.value) {
action.undo()
command.undo()
true
} else {
false
@ -120,7 +126,7 @@ class TextModelUndo(
override fun redo(): Boolean =
if (canRedo.value) {
action.execute()
command.execute()
true
} else {
false

View File

@ -1,5 +1,6 @@
package world.phantasmal.web.questEditor.widgets
import kotlinx.browser.window
import org.w3c.dom.Node
import world.phantasmal.core.disposable.disposable
import world.phantasmal.web.externals.monacoEditor.*
@ -62,20 +63,32 @@ class AsmEditorWidget(private val ctrl: AsmEditorController) : Widget() {
// Undo/redo.
observe(ctrl.didUndo) {
editor.focus()
editor.trigger(
source = AsmEditorWidget::class.simpleName,
handlerId = "undo",
payload = undefined,
)
// TODO: Remove this hack.
window.setTimeout({
if (disposed) return@setTimeout
editor.trigger(
source = AsmEditorWidget::class.simpleName,
handlerId = "undo",
payload = undefined,
)
}, 0)
}
observe(ctrl.didRedo) {
editor.focus()
editor.trigger(
source = AsmEditorWidget::class.simpleName,
handlerId = "redo",
payload = undefined,
)
// TODO: Remove this hack.
window.setTimeout({
if (disposed) return@setTimeout
editor.trigger(
source = AsmEditorWidget::class.simpleName,
handlerId = "redo",
payload = undefined,
)
}, 0)
}
editor.onDidFocusEditorWidget(ctrl::makeUndoCurrent)

Some files were not shown because too many files have changed in this diff Show More