diff --git a/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt b/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt index 8ab43e03..036d4986 100644 --- a/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt +++ b/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt @@ -9,12 +9,12 @@ class TrackedDisposableTests { @Test fun is_correctly_tracked() { assertFails { - checkNoDisposableLeaks { + DisposableTracking.checkNoLeaks { object : TrackedDisposable() {} } } - checkNoDisposableLeaks { + DisposableTracking.checkNoLeaks { val disposable = object : TrackedDisposable() {} disposable.dispose() } diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt index 6343ae91..2d0752e0 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt @@ -25,7 +25,7 @@ abstract class AbstractDependency : Dependency { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } - ChangeManager.changeDependency { + MutationManager.changeDependency { emitDependencyInvalidated() block() } diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt index 4ce26875..89fe58e2 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt @@ -23,7 +23,7 @@ class CallbackChangeObserver>( } override fun dependencyInvalidated(dependency: Dependency<*>) { - ChangeManager.invalidated(this) + MutationManager.invalidated(this) } override fun pull() { diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt index 6eab7214..5c818dbc 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt @@ -25,7 +25,7 @@ class CallbackObserver( } override fun dependencyInvalidated(dependency: Dependency<*>) { - ChangeManager.invalidated(this) + MutationManager.invalidated(this) } override fun pull() { diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt deleted file mode 100644 index 277a6bbe..00000000 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt +++ /dev/null @@ -1,10 +0,0 @@ -package world.phantasmal.observable - -/** - * Defer propagation of changes to observables until the end of a code block. All changes to - * observables in a single change set won't be propagated to their dependencies until the change set - * is completed. - */ -fun change(block: () -> Unit) { - ChangeManager.inChangeSet(block) -} diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt new file mode 100644 index 00000000..6ebd6b6e --- /dev/null +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt @@ -0,0 +1,19 @@ +package world.phantasmal.observable + +/** + * Defer propagation of changes to observables until the end of a code block. All changes to + * observables in a single mutation won't be propagated to their dependencies until the mutation is + * completed. + */ +inline fun mutate(block: () -> Unit) { + MutationManager.mutate(block) +} + +/** + * Schedule a mutation to run right after the current mutation finishes. You can use this to change + * observables in an observer callback. This is usually a bad idea, but sometimes the situation + * where you have to change observables in response to observables changing is very hard to avoid. + */ +fun mutateDeferred(block: () -> Unit) { + MutationManager.mutateDeferred(block) +} diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/ChangeManager.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/MutationManager.kt similarity index 62% rename from observable/src/commonMain/kotlin/world/phantasmal/observable/ChangeManager.kt rename to observable/src/commonMain/kotlin/world/phantasmal/observable/MutationManager.kt index 3fc0162b..1fa503d9 100644 --- a/observable/src/commonMain/kotlin/world/phantasmal/observable/ChangeManager.kt +++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/MutationManager.kt @@ -8,17 +8,32 @@ import kotlin.contracts.contract // 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 { +object MutationManager { private val invalidatedLeaves = HashSet() /** Whether a dependency's value is changing at the moment. */ private var dependencyChanging = false - fun inChangeSet(block: () -> Unit) { - // TODO: Implement inChangeSet correctly. + private val deferredMutations: MutableList<() -> Unit> = mutableListOf() + private var applyingDeferredMutations = false + + inline fun mutate(block: () -> Unit) { + contract { + callsInPlace(block, EXACTLY_ONCE) + } + + // TODO: Implement mutate correctly. block() } + fun mutateDeferred(block: () -> Unit) { + if (dependencyChanging) { + deferredMutations.add(block) + } else { + block() + } + } + fun invalidated(dependent: LeafDependent) { invalidatedLeaves.add(dependent) } @@ -51,6 +66,21 @@ object ChangeManager { } finally { dependencyChanging = false invalidatedLeaves.clear() + + if (!applyingDeferredMutations) { + try { + applyingDeferredMutations = true + var i = 0 + + while (i < deferredMutations.size) { + deferredMutations[i]() + i++ + } + } finally { + applyingDeferredMutations = false + deferredMutations.clear() + } + } } } } diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt new file mode 100644 index 00000000..81272a46 --- /dev/null +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt @@ -0,0 +1,29 @@ +package world.phantasmal.observable + +import world.phantasmal.observable.cell.mutableCell +import world.phantasmal.observable.test.ObservableTestSuite +import kotlin.test.Test +import kotlin.test.assertEquals + +class MutationTests : ObservableTestSuite { + @Test + fun can_change_observed_cell_with_mutateDeferred() = test { + val cell = mutableCell(0) + var observerCalls = 0 + + disposer.add(cell.observe { + observerCalls++ + + if (it < 10) { + mutateDeferred { + cell.value++ + } + } + }) + + cell.value = 1 + + assertEquals(10, observerCalls) + assertEquals(10, cell.value) + } +} diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt index 1f6cb8e4..29eded7c 100644 --- a/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt +++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt @@ -1,6 +1,6 @@ package world.phantasmal.observable.cell -import world.phantasmal.observable.change +import world.phantasmal.observable.mutate import world.phantasmal.observable.test.ObservableTestSuite import kotlin.test.Test import kotlin.test.assertEquals @@ -16,7 +16,7 @@ class ChangeTests : ObservableTestSuite { disposer.add(dependent.observeChange { dependentObservedValue = it.value }) assertFails { - change { + mutate { dependency.value = 11 throw Exception() } @@ -27,7 +27,7 @@ class ChangeTests : ObservableTestSuite { assertEquals(22, dependent.value) // The machinery behind change is still in a valid state. - change { + mutate { dependency.value = 13 } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt b/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt index 6e4d0826..acdbfd7b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt @@ -1,8 +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.observable.mutateDeferred import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.stores.UiStore import world.phantasmal.webui.controllers.Tab @@ -38,11 +38,11 @@ open class PathAwareTabContainerController( super.visibleChanged(visible) if (visible) { - // TODO: Remove this hack. - window.setTimeout({ - if (disposed) return@setTimeout - setPathPrefix(activeTab.value, replace = true) - }, 0) + mutateDeferred { + if (!disposed) { + setPathPrefix(activeTab.value, replace = true) + } + } } } diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt index b8c6564a..687f022b 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt @@ -20,19 +20,21 @@ abstract class Renderer : DisposableContainer() { private var animationFrameHandle: Int = 0 fun startRendering() { - logger.trace { "${this::class.simpleName} - start rendering." } - if (!rendering) { + logger.trace { "${this::class.simpleName} - start rendering." } + rendering = true renderLoop() } } fun stopRendering() { - logger.trace { "${this::class.simpleName} - stop rendering." } + if (rendering) { + logger.trace { "${this::class.simpleName} - stop rendering." } - rendering = false - window.cancelAnimationFrame(animationFrameHandle) + rendering = false + window.cancelAnimationFrame(animationFrameHandle) + } } open fun setSize(width: Int, height: Int) { diff --git a/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt b/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt index 5e6c00b4..cb195b04 100644 --- a/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt @@ -12,14 +12,6 @@ class RendererWidget( div { className = "pw-core-renderer" - observeNow(selfOrAncestorVisible) { visible -> - if (visible) { - renderer.startRendering() - } else { - renderer.stopRendering() - } - } - addDisposable(size.observeChange { (size) -> renderer.setSize(size.width.toInt(), size.height.toInt()) }) @@ -27,6 +19,14 @@ class RendererWidget( append(renderer.canvas) } + override fun selfAndAncestorsVisibleChanged(visible: Boolean) { + if (visible) { + renderer.startRendering() + } else { + renderer.stopRendering() + } + } + companion object { init { @Suppress("CssUnusedSymbol") diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt index fb73d030..3aaf6b69 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt @@ -6,7 +6,7 @@ import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.list.ListCell import world.phantasmal.observable.cell.list.listCell import world.phantasmal.observable.cell.mutableCell -import world.phantasmal.observable.change +import world.phantasmal.observable.mutate import world.phantasmal.web.core.minus import world.phantasmal.web.core.rendering.conversion.vec3ToEuler import world.phantasmal.web.core.rendering.conversion.vec3ToThree @@ -60,7 +60,7 @@ abstract class QuestEntityModel>( }) open fun setSectionId(sectionId: Int) { - change { + mutate { entity.sectionId = sectionId.toShort() _sectionId.value = sectionId @@ -83,7 +83,7 @@ abstract class QuestEntityModel>( "Quest entities can't be moved across areas." } - change { + mutate { entity.sectionId = section.id.toShort() _sectionId.value = section.id @@ -106,7 +106,7 @@ abstract class QuestEntityModel>( } fun setPosition(pos: Vector3) { - change { + mutate { entity.setPosition(pos.x.toFloat(), pos.y.toFloat(), pos.z.toFloat()) _position.value = pos @@ -120,7 +120,7 @@ abstract class QuestEntityModel>( } fun setWorldPosition(pos: Vector3) { - change { + mutate { val section = section.value val relPos = @@ -135,7 +135,7 @@ abstract class QuestEntityModel>( } fun setRotation(rot: Euler) { - change { + mutate { floorModEuler(rot) entity.setRotation(rot.x.toFloat(), rot.y.toFloat(), rot.z.toFloat()) @@ -155,7 +155,7 @@ abstract class QuestEntityModel>( } fun setWorldRotation(rot: Euler) { - change { + mutate { floorModEuler(rot) val section = section.value diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt index 31be66c6..2b902018 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt @@ -9,6 +9,7 @@ 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.observable.mutateDeferred import world.phantasmal.psolib.asm.assemble import world.phantasmal.psolib.asm.disassemble import world.phantasmal.web.core.undo.UndoManager @@ -111,8 +112,7 @@ class AsmStore( } private fun setTextModel(quest: QuestModel?, inlineStackArgs: Boolean) { - // TODO: Remove this hack. - window.setTimeout({ + mutateDeferred { setBytecodeIrTimeout?.let { it -> window.clearTimeout(it) setBytecodeIrTimeout = null @@ -120,7 +120,7 @@ class AsmStore( modelDisposer.disposeAll() - quest ?: return@setTimeout + quest ?: return@mutateDeferred val asm = disassemble(quest.bytecodeIr, inlineStackArgs) asmAnalyser.setAsm(asm, inlineStackArgs) @@ -147,7 +147,7 @@ class AsmStore( // TODO: Update breakpoints. } } - }, 0) + } } private fun setBytecodeIr() { diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt index 4057ded9..4daa07c4 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt @@ -7,7 +7,7 @@ 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.observable.mutate import world.phantasmal.psolib.Episode import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.commands.Command @@ -174,14 +174,14 @@ class QuestEditorStore( } fun addEvent(quest: QuestModel, index: Int, event: QuestEventModel) { - change { + mutate { quest.addEvent(index, event) setSelectedEvent(event) } } fun removeEvent(quest: QuestModel, event: QuestEventModel) { - change { + mutate { setSelectedEvent(null) quest.removeEvent(event) } @@ -218,28 +218,28 @@ class QuestEditorStore( setter: (QuestEventModel, T) -> Unit, value: T, ) { - change { + mutate { setSelectedEvent(event) setter(event, value) } } fun addEventAction(event: QuestEventModel, action: QuestEventActionModel) { - change { + mutate { setSelectedEvent(event) event.addAction(action) } } fun addEventAction(event: QuestEventModel, index: Int, action: QuestEventActionModel) { - change { + mutate { setSelectedEvent(event) event.addAction(index, action) } } fun removeEventAction(event: QuestEventModel, action: QuestEventActionModel) { - change { + mutate { setSelectedEvent(event) event.removeAction(action) } @@ -251,7 +251,7 @@ class QuestEditorStore( setter: (Action, T) -> Unit, value: T, ) { - change { + mutate { setSelectedEvent(event) setter(action, value) } @@ -272,14 +272,14 @@ class QuestEditorStore( } fun addEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) { - change { + mutate { quest.addEntity(entity) setSelectedEntity(entity) } } fun removeEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) { - change { + mutate { if (entity == _selectedEntity.value) { _selectedEntity.value = null } @@ -289,7 +289,7 @@ class QuestEditorStore( } fun setEntityPosition(entity: QuestEntityModel<*, *>, sectionId: Int?, position: Vector3) { - change { + mutate { setSelectedEntity(entity) sectionId?.let { setEntitySection(entity, it) } entity.setPosition(position) @@ -297,14 +297,14 @@ class QuestEditorStore( } fun setEntityRotation(entity: QuestEntityModel<*, *>, rotation: Euler) { - change { + mutate { setSelectedEntity(entity) entity.setRotation(rotation) } } fun setEntityWorldRotation(entity: QuestEntityModel<*, *>, rotation: Euler) { - change { + mutate { setSelectedEntity(entity) entity.setWorldRotation(rotation) } @@ -315,14 +315,14 @@ class QuestEditorStore( setter: (Entity, T) -> Unit, value: T, ) { - change { + mutate { setSelectedEntity(entity) setter(entity, value) } } fun setEntityProp(entity: QuestEntityModel<*, *>, prop: QuestEntityPropModel, value: Any) { - change { + mutate { setSelectedEntity(entity) prop.setValue(value) } @@ -336,14 +336,14 @@ class QuestEditorStore( } fun setEntitySectionId(entity: QuestEntityModel<*, *>, sectionId: Int) { - change { + mutate { setSelectedEntity(entity) entity.setSectionId(sectionId) } } fun setEntitySection(entity: QuestEntityModel<*, *>, section: SectionModel) { - change { + mutate { setSelectedEntity(entity) entity.setSection(section) } diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt index e20b3d11..8ae07cc9 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt @@ -1,12 +1,12 @@ 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.observable.mutateDeferred import world.phantasmal.web.core.commands.Command import world.phantasmal.web.core.undo.Undo import world.phantasmal.web.core.undo.UndoManager @@ -64,15 +64,14 @@ class TextModelUndo( } private fun onModelChange(model: ITextModel?) { - // TODO: Remove this hack. - window.setTimeout({ - if (disposed) return@setTimeout + mutateDeferred { + if (disposed) return@mutateDeferred modelChangeObserver?.dispose() if (model == null) { reset() - return@setTimeout + return@mutateDeferred } _canUndo.value = false @@ -113,7 +112,7 @@ class TextModelUndo( currentVersionId.value = versionId } - }, 0) + } } override fun undo(): Boolean = diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt index 3876b511..4d2ed1eb 100644 --- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt +++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt @@ -1,8 +1,8 @@ package world.phantasmal.web.questEditor.widgets -import kotlinx.browser.window import org.w3c.dom.Node import world.phantasmal.core.disposable.disposable +import world.phantasmal.observable.mutateDeferred import world.phantasmal.web.externals.monacoEditor.* import world.phantasmal.web.questEditor.asm.monaco.EditorHistory import world.phantasmal.web.questEditor.controllers.AsmEditorController @@ -64,31 +64,29 @@ class AsmEditorWidget(private val ctrl: AsmEditorController) : Widget() { observe(ctrl.didUndo) { editor.focus() - // TODO: Remove this hack. - window.setTimeout({ - if (disposed) return@setTimeout - - editor.trigger( - source = AsmEditorWidget::class.simpleName, - handlerId = "undo", - payload = undefined, - ) - }, 0) + mutateDeferred { + if (!disposed) { + editor.trigger( + source = AsmEditorWidget::class.simpleName, + handlerId = "undo", + payload = undefined, + ) + } + } } observe(ctrl.didRedo) { editor.focus() - // TODO: Remove this hack. - window.setTimeout({ - if (disposed) return@setTimeout - - editor.trigger( - source = AsmEditorWidget::class.simpleName, - handlerId = "redo", - payload = undefined, - ) - }, 0) + mutateDeferred { + if (!disposed) { + editor.trigger( + source = AsmEditorWidget::class.simpleName, + handlerId = "redo", + payload = undefined, + ) + } + } } editor.onDidFocusEditorWidget(ctrl::makeUndoCurrent) diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt index c3e862f8..31dc923f 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt @@ -17,7 +17,7 @@ import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.isNotNull import world.phantasmal.observable.cell.map import world.phantasmal.observable.cell.mutableCell -import world.phantasmal.observable.change +import world.phantasmal.observable.mutate import world.phantasmal.web.core.files.cursor import world.phantasmal.web.viewer.stores.NinjaGeometry import world.phantasmal.web.viewer.stores.ViewerStore @@ -166,7 +166,7 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() { result.addProblem(Severity.Error, "Couldn't parse files.", cause = e) } - change { + mutate { ninjaGeometry?.let(store::setCurrentNinjaGeometry) textures?.let(store::setCurrentTextures) ninjaMotion?.let(store::setCurrentNinjaMotion) diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt index 6ea633ad..9fc10d05 100644 --- a/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt +++ b/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt @@ -10,7 +10,7 @@ import world.phantasmal.observable.cell.list.ListCell import world.phantasmal.observable.cell.list.mutableListCell import world.phantasmal.observable.cell.map import world.phantasmal.observable.cell.mutableCell -import world.phantasmal.observable.change +import world.phantasmal.observable.mutate import world.phantasmal.psolib.fileFormats.AreaGeometry import world.phantasmal.psolib.fileFormats.CollisionGeometry import world.phantasmal.psolib.fileFormats.ninja.NinjaObject @@ -176,7 +176,7 @@ class ViewerStore( } fun setCurrentNinjaGeometry(geometry: NinjaGeometry?) { - change { + mutate { if (_currentCharacterClass.value != null) { setCurrentCharacterClassValue(null) _currentTextures.clear() @@ -215,7 +215,7 @@ class ViewerStore( } fun setCurrentNinjaMotion(njm: NjMotion) { - change { + mutate { _currentNinjaMotion.value = njm _animationPlaying.value = true } @@ -267,7 +267,7 @@ class ViewerStore( val ninjaObject = characterClassAssetLoader.loadNinjaObject(char) val textures = characterClassAssetLoader.loadXvrTextures(char, sectionId, body) - change { + mutate { if (clearAnimation) { _currentAnimation.value = null _currentNinjaMotion.value = null @@ -279,7 +279,7 @@ class ViewerStore( } catch (e: Exception) { logger.error(e) { "Couldn't load Ninja model for $char." } - change { + mutate { _currentAnimation.value = null _currentNinjaMotion.value = null _currentNinjaGeometry.value = null @@ -292,7 +292,7 @@ class ViewerStore( try { val ninjaMotion = animationAssetLoader.loadAnimation(animation.filePath) - change { + mutate { _currentNinjaMotion.value = ninjaMotion _animationPlaying.value = true } diff --git a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt index 4ef78e28..5c14a5a0 100644 --- a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt +++ b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt @@ -23,6 +23,7 @@ import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.QuestEditorStore +import world.phantasmal.web.shared.JSON_FORMAT import world.phantasmal.web.viewer.loading.AnimationAssetLoader import world.phantasmal.web.viewer.loading.CharacterClassAssetLoader import world.phantasmal.web.viewer.stores.ViewerStore @@ -37,9 +38,7 @@ class TestComponents(private val ctx: TestContext) { var httpClient: HttpClient by default { HttpClient { install(JsonFeature) { - serializer = KotlinxSerializer(kotlinx.serialization.json.Json { - ignoreUnknownKeys = true - }) + serializer = KotlinxSerializer(JSON_FORMAT) } }.also { ctx.disposer.add(disposable { it.cancel() }) diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt index 09c0034f..b2e47ebe 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt @@ -1,9 +1,9 @@ package world.phantasmal.webui.widgets -import kotlinx.browser.window import org.w3c.dom.Node import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.trueCell +import world.phantasmal.observable.mutateDeferred import world.phantasmal.webui.dom.div class LazyLoader( @@ -21,11 +21,11 @@ class LazyLoader( if (v && !initialized) { initialized = true - // TODO: Remove this hack. - window.setTimeout({ - if (disposed) return@setTimeout - addChild(createWidget()) - }, 0) + mutateDeferred { + if (!disposed) { + addChild(createWidget()) + } + } } } } diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt index 5b6c5fbd..ab1aacfb 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt @@ -54,8 +54,8 @@ class TabContainer( } } - init { - observeNow(selfOrAncestorVisible, ctrl::visibleChanged) + override fun selfAndAncestorsVisibleChanged(visible: Boolean) { + ctrl.visibleChanged(visible) } companion object { diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt index 70e947d9..4212f5fd 100644 --- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt +++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt @@ -1,7 +1,6 @@ package world.phantasmal.webui.widgets import kotlinx.browser.document -import kotlinx.browser.window import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -13,7 +12,9 @@ import org.w3c.dom.pointerevents.PointerEvent import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.DisposableSupervisedScope import world.phantasmal.core.disposable.disposable -import world.phantasmal.observable.cell.* +import world.phantasmal.observable.cell.Cell +import world.phantasmal.observable.cell.nullCell +import world.phantasmal.observable.cell.trueCell import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.dom.* @@ -29,7 +30,9 @@ abstract class Widget( val enabled: Cell = trueCell(), val tooltip: Cell = nullCell(), ) : DisposableContainer() { - private val _ancestorVisible = mutableCell(true) + protected var ancestorsVisible = true + private set + private val _children = mutableListOf() private val _size = HTMLElementSizeCell() @@ -39,14 +42,13 @@ abstract class Widget( observeNow(visible) { visible -> el.hidden = !visible - // TODO: Remove this hack. - window.setTimeout({ - if (disposed) return@setTimeout + val selfAndAncestorsVisible = visible && ancestorsVisible - for (child in children) { - setAncestorVisible(child, visible && ancestorVisible.value) - } - }, 0) + selfAndAncestorsVisibleChanged(selfAndAncestorsVisible) + + for (child in children) { + setAncestorsVisible(child, selfAndAncestorsVisible) + } } observeNow(enabled) { enabled -> @@ -82,16 +84,6 @@ abstract class Widget( */ val element: HTMLElement by elementDelegate - /** - * True if this widget's ancestors are [visible], false otherwise. - */ - val ancestorVisible: Cell get() = _ancestorVisible - - /** - * True if this widget and all of its ancestors are [visible], false otherwise. - */ - val selfOrAncestorVisible: Cell = visible and ancestorVisible - val size: Cell get() = _size val children: List get() = _children @@ -155,7 +147,7 @@ abstract class Widget( } _children.add(child) - setAncestorVisible(child, selfOrAncestorVisible.value) + setAncestorsVisible(child, visible.value && ancestorsVisible) appendChild(child.element) return child } @@ -205,7 +197,11 @@ abstract class Widget( addDisposable(bindDisposableChildrenTo(this, list, create)) } - fun Element.onDrag( + protected open fun selfAndAncestorsVisibleChanged(visible: Boolean) { + // Do nothing. + } + + protected fun Element.onDrag( onPointerDown: (e: PointerEvent) -> Boolean, onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean, onPointerUp: (e: PointerEvent) -> Unit = {}, @@ -229,13 +225,15 @@ abstract class Widget( STYLE_EL.append(style) } - protected fun setAncestorVisible(widget: Widget, visible: Boolean) { - widget._ancestorVisible.value = visible + protected fun setAncestorsVisible(widget: Widget, ancestorsVisible: Boolean) { + widget.ancestorsVisible = ancestorsVisible if (!widget.visible.value) return - widget.children.forEach { - setAncestorVisible(it, widget.selfOrAncestorVisible.value) + widget.selfAndAncestorsVisibleChanged(ancestorsVisible) + + for (child in widget.children) { + setAncestorsVisible(child, ancestorsVisible) } } } diff --git a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt index 5a0e3227..421462f0 100644 --- a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt +++ b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt @@ -1,7 +1,5 @@ package world.phantasmal.webui.widgets -import kotlinx.browser.window -import kotlinx.coroutines.await import org.w3c.dom.Node import world.phantasmal.observable.cell.Cell import world.phantasmal.observable.cell.falseCell @@ -10,7 +8,6 @@ import world.phantasmal.observable.cell.mutableCell import world.phantasmal.observable.cell.trueCell import world.phantasmal.webui.dom.div import world.phantasmal.webui.test.WebuiTestSuite -import kotlin.js.Promise import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -45,48 +42,42 @@ class WidgetTests : WebuiTestSuite { } @Test - fun ancestorVisible_and_selfOrAncestorVisible_update_when_visible_changes() = testAsync { - val parentVisible = mutableCell(true) - val childVisible = mutableCell(true) - val grandChild = DummyWidget() - val child = DummyWidget(childVisible, grandChild) - val parent = disposer.add(DummyWidget(parentVisible, child)) + fun ancestorVisible_updates_and_selfAndAncestorsVisibleChanged_is_called_when_visible_changes() = + testAsync { + val parentVisible = mutableCell(true) + val childVisible = mutableCell(true) + val grandChild = DummyWidget() + val child = DummyWidget(childVisible, grandChild) + val parent = disposer.add(DummyWidget(parentVisible, child)) - parent.element // Ensure widgets are fully initialized. + parent.element // Ensure widgets are fully initialized. - assertTrue(parent.ancestorVisible.value) - assertTrue(parent.selfOrAncestorVisible.value) - assertTrue(child.ancestorVisible.value) - assertTrue(child.selfOrAncestorVisible.value) - assertTrue(grandChild.ancestorVisible.value) - assertTrue(grandChild.selfOrAncestorVisible.value) + assertTrue(parent.publicAncestorsVisible) + assertEquals(true, parent.selfAndAncestorsVisible) + assertTrue(child.publicAncestorsVisible) + assertEquals(true, child.selfAndAncestorsVisible) + assertTrue(grandChild.publicAncestorsVisible) + assertEquals(true, grandChild.selfAndAncestorsVisible) - parentVisible.value = false - setTimeoutHack() + parentVisible.value = false - assertTrue(parent.ancestorVisible.value) - assertFalse(parent.selfOrAncestorVisible.value) - assertFalse(child.ancestorVisible.value) - assertFalse(child.selfOrAncestorVisible.value) - assertFalse(grandChild.ancestorVisible.value) - assertFalse(grandChild.selfOrAncestorVisible.value) + assertTrue(parent.publicAncestorsVisible) + assertEquals(false, parent.selfAndAncestorsVisible) + assertFalse(child.publicAncestorsVisible) + assertEquals(false, child.selfAndAncestorsVisible) + assertFalse(grandChild.publicAncestorsVisible) + assertEquals(false, grandChild.selfAndAncestorsVisible) - childVisible.value = false - parentVisible.value = true - setTimeoutHack() + childVisible.value = false + parentVisible.value = true - assertTrue(parent.ancestorVisible.value) - assertTrue(parent.selfOrAncestorVisible.value) - assertTrue(child.ancestorVisible.value) - assertFalse(child.selfOrAncestorVisible.value) - assertFalse(grandChild.ancestorVisible.value) - assertFalse(grandChild.selfOrAncestorVisible.value) - } - - // TODO: Remove test setTimeout hack when setTimeout hack in Widget visible observer is removed. - private suspend fun setTimeoutHack() { - Promise { resolve, _ -> window.setTimeout(resolve, 10) }.await() - } + assertTrue(parent.publicAncestorsVisible) + assertEquals(true, parent.selfAndAncestorsVisible) + assertTrue(child.publicAncestorsVisible) + assertEquals(false, child.selfAndAncestorsVisible) + assertFalse(grandChild.publicAncestorsVisible) + assertEquals(false, grandChild.selfAndAncestorsVisible) + } @Test fun added_child_widgets_have_ancestorVisible_and_selfOrAncestorVisible_set_correctly() = @@ -94,10 +85,10 @@ class WidgetTests : WebuiTestSuite { val parent = disposer.add(DummyWidget(visible = falseCell())) val child = parent.addChild(DummyWidget()) - assertTrue(parent.ancestorVisible.value) - assertFalse(parent.selfOrAncestorVisible.value) - assertFalse(child.ancestorVisible.value) - assertFalse(child.selfOrAncestorVisible.value) + assertTrue(parent.publicAncestorsVisible) + assertEquals(false, parent.selfAndAncestorsVisible) + assertFalse(child.publicAncestorsVisible) + assertEquals(false, child.selfAndAncestorsVisible) } @Test @@ -149,6 +140,10 @@ class WidgetTests : WebuiTestSuite { visible: Cell = trueCell(), private val child: Widget? = null, ) : Widget(visible = visible) { + val publicAncestorsVisible: Boolean get() = ancestorsVisible + var selfAndAncestorsVisible: Boolean? = null + private set + override fun Node.createElement() = div { child?.let { addChild(it) } @@ -156,5 +151,9 @@ class WidgetTests : WebuiTestSuite { fun addChild(child: T): T = element.addChild(child) + + override fun selfAndAncestorsVisibleChanged(visible: Boolean) { + selfAndAncestorsVisible = visible + } } }