Added undo/redo and made entity translation undoable.

This commit is contained in:
Daan Vanden Bosch 2020-11-11 22:01:00 +01:00
parent bb6f4aa352
commit 44d5918a1e
68 changed files with 994 additions and 306 deletions

View File

@ -18,15 +18,36 @@ interface Val<out T> : Observable<T> {
*/ */
fun observe(callNow: Boolean = false, observer: Observer<T>): Disposable fun observe(callNow: Boolean = false, observer: Observer<T>): Disposable
/**
* Map a transformation function over this val.
*
* @param transform called whenever this val changes
*/
fun <R> map(transform: (T) -> R): Val<R> = fun <R> map(transform: (T) -> R): Val<R> =
MappedVal(listOf(this)) { transform(value) } MappedVal(listOf(this)) { transform(value) }
/**
* Map a transformation function over this val and another val.
*
* @param transform called whenever this val or [v2] changes
*/
fun <T2, R> map(v2: Val<T2>, transform: (T, T2) -> R): Val<R> = fun <T2, R> map(v2: Val<T2>, transform: (T, T2) -> R): Val<R> =
MappedVal(listOf(this, v2)) { transform(value, v2.value) } MappedVal(listOf(this, v2)) { transform(value, v2.value) }
/**
* Map a transformation function over this val and two other vals.
*
* @param transform called whenever this val, [v2] or [v3] changes
*/
fun <T2, T3, R> map(v2: Val<T2>, v3: Val<T3>, transform: (T, T2, T3) -> R): Val<R> = fun <T2, T3, R> map(v2: Val<T2>, v3: Val<T3>, transform: (T, T2, T3) -> R): Val<R> =
MappedVal(listOf(this, v2, v3)) { transform(value, v2.value, v3.value) } MappedVal(listOf(this, v2, v3)) { transform(value, v2.value, v3.value) }
/**
* Map a transformation function that returns a val over this val. The resulting val will change
* when this val changes and when the val returned by [transform] changes.
*
* @param transform called whenever this val changes
*/
fun <R> flatMap(transform: (T) -> Val<R>): Val<R> = fun <R> flatMap(transform: (T) -> Val<R>): Val<R> =
FlatMappedVal(listOf(this)) { transform(value) } FlatMappedVal(listOf(this)) { transform(value) }
} }

View File

@ -1,5 +1,35 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
infix fun Val<Any?>.eq(value: Any?): Val<Boolean> =
map { it == value }
infix fun Val<Any?>.eq(value: Val<Any?>): Val<Boolean> =
map(value) { a, b -> a == b }
infix fun Val<Any?>.ne(value: Any?): Val<Boolean> =
map { it != value }
infix fun Val<Any?>.ne(value: Val<Any?>): Val<Boolean> =
map(value) { a, b -> a != b }
fun Val<Any?>.isNull(): Val<Boolean> =
map { it == null }
fun Val<Any?>.isNotNull(): Val<Boolean> =
map { it != null }
infix fun <T : Comparable<T>> Val<T>.gt(value: T): Val<Boolean> =
map { it > value }
infix fun <T : Comparable<T>> Val<T>.gt(value: Val<T>): Val<Boolean> =
map(value) { a, b -> a > b }
infix fun <T : Comparable<T>> Val<T>.lt(value: T): Val<Boolean> =
map { it < value }
infix fun <T : Comparable<T>> Val<T>.lt(value: Val<T>): Val<Boolean> =
map(value) { a, b -> a < b }
infix fun Val<Boolean>.and(other: Val<Boolean>): Val<Boolean> = infix fun Val<Boolean>.and(other: Val<Boolean>): Val<Boolean> =
map(other) { a, b -> a && b } map(other) { a, b -> a && b }

View File

@ -6,14 +6,11 @@ import world.phantasmal.observable.ChangeEvent
import world.phantasmal.observable.Observable import world.phantasmal.observable.Observable
import world.phantasmal.observable.Observer import world.phantasmal.observable.Observer
import world.phantasmal.observable.value.AbstractVal import world.phantasmal.observable.value.AbstractVal
import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
abstract class AbstractListVal<E>( abstract class AbstractListVal<E>(
protected val elements: MutableList<E>, protected val elements: MutableList<E>,
private val extractObservables: ObservablesExtractor<E>? , private val extractObservables: ObservablesExtractor<E>?,
): AbstractVal<List<E>>(), ListVal<E> { ) : AbstractVal<List<E>>(), ListVal<E> {
/** /**
* Internal observers which observe observables related to this list's elements so that their * Internal observers which observe observables related to this list's elements so that their
* changes can be propagated via ElementChange events. * changes can be propagated via ElementChange events.
@ -25,6 +22,9 @@ abstract class AbstractListVal<E>(
*/ */
protected val listObservers = mutableListOf<ListValObserver<E>>() protected val listObservers = mutableListOf<ListValObserver<E>>()
override fun get(index: Int): E =
elements[index]
override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable { override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable {
if (elementObservers.isEmpty() && extractObservables != null) { if (elementObservers.isEmpty() && extractObservables != null) {
replaceElementObservers(0, elementObservers.size, elements) replaceElementObservers(0, elementObservers.size, elements)

View File

@ -6,6 +6,8 @@ import world.phantasmal.observable.value.Val
interface ListVal<E> : Val<List<E>> { interface ListVal<E> : Val<List<E>> {
val sizeVal: Val<Int> val sizeVal: Val<Int>
operator fun get(index: Int): E
fun observeList(callNow: Boolean = false, observer: ListValObserver<E>): Disposable fun observeList(callNow: Boolean = false, observer: ListValObserver<E>): Disposable
fun sumBy(selector: (E) -> Int): Val<Int> = fun sumBy(selector: (E) -> Int): Val<Int> =

View File

@ -3,7 +3,7 @@ package world.phantasmal.observable.value.list
import world.phantasmal.observable.value.MutableVal import world.phantasmal.observable.value.MutableVal
interface MutableListVal<E> : ListVal<E>, MutableVal<List<E>> { interface MutableListVal<E> : ListVal<E>, MutableVal<List<E>> {
fun set(index: Int, element: E): E operator fun set(index: Int, element: E): E
fun add(element: E) fun add(element: E)
@ -15,5 +15,7 @@ interface MutableListVal<E> : ListVal<E>, MutableVal<List<E>> {
fun replaceAll(elements: Sequence<E>) fun replaceAll(elements: Sequence<E>)
fun splice(from: Int, removeCount: Int, newElement: E)
fun clear() fun clear()
} }

View File

@ -25,7 +25,10 @@ class SimpleListVal<E>(
override val sizeVal: Val<Int> = _sizeVal override val sizeVal: Val<Int> = _sizeVal
override fun set(index: Int, element: E): E { override operator fun get(index: Int): E =
elements[index]
override operator fun set(index: Int, element: E): E {
val removed = elements.set(index, element) val removed = elements.set(index, element)
finalizeUpdate(ListValChangeEvent.Change(index, listOf(removed), listOf(element))) finalizeUpdate(ListValChangeEvent.Change(index, listOf(removed), listOf(element)))
return removed return removed
@ -62,6 +65,13 @@ class SimpleListVal<E>(
finalizeUpdate(ListValChangeEvent.Change(0, removed, this.elements)) finalizeUpdate(ListValChangeEvent.Change(0, removed, this.elements))
} }
override fun splice(from: Int, removeCount: Int, newElement: E) {
val removed = ArrayList(elements.subList(from, from + removeCount))
repeat(removeCount) { elements.removeAt(from) }
elements.add(from, newElement)
finalizeUpdate(ListValChangeEvent.Change(from, removed, listOf(newElement)))
}
override fun clear() { override fun clear() {
val removed = ArrayList(elements) val removed = ArrayList(elements)
elements.clear() elements.clear()

View File

@ -7,11 +7,14 @@ import world.phantasmal.observable.Observer
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
class StaticListVal<E>(elements: List<E>) : ListVal<E> { class StaticListVal<E>(private val elements: List<E>) : ListVal<E> {
override val sizeVal: Val<Int> = value(elements.size) override val sizeVal: Val<Int> = value(elements.size)
override val value: List<E> = elements override val value: List<E> = elements
override fun get(index: Int): E =
elements[index]
override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable { override fun observe(callNow: Boolean, observer: Observer<List<E>>): Disposable {
if (callNow) { if (callNow) {
observer(ChangeEvent(value)) observer(ChangeEvent(value))

View File

@ -4,14 +4,20 @@ import world.phantasmal.observable.test.ObservableTestSuite
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
typealias ObservableAndEmit = Pair<Observable<*>, () -> Unit> open class ObservableAndEmit<T, out O : Observable<T>>(
val observable: O,
val emit: () -> Unit,
) {
operator fun component1() = observable
operator fun component2() = emit
}
/** /**
* Test suite for all [Observable] implementations. There is a subclass of this suite for every * Test suite for all [Observable] implementations. There is a subclass of this suite for every
* [Observable] implementation. * [Observable] implementation.
*/ */
abstract class ObservableTests : ObservableTestSuite() { abstract class ObservableTests : ObservableTestSuite() {
protected abstract fun create(): ObservableAndEmit protected abstract fun create(): ObservableAndEmit<*, Observable<*>>
@Test @Test
fun observable_calls_observers_when_events_are_emitted() = test { fun observable_calls_observers_when_events_are_emitted() = test {

View File

@ -1,7 +1,7 @@
package world.phantasmal.observable package world.phantasmal.observable
class SimpleEmitterTests : ObservableTests() { class SimpleEmitterTests : ObservableTests() {
override fun create(): ObservableAndEmit { override fun create(): ObservableAndEmit<*, SimpleEmitter<*>> {
val observable = SimpleEmitter<Any>() val observable = SimpleEmitter<Any>()
return ObservableAndEmit(observable) { observable.emit(ChangeEvent(Any())) } return ObservableAndEmit(observable) { observable.emit(ChangeEvent(Any())) }
} }

View File

@ -1,15 +1,16 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
import world.phantasmal.observable.ObservableAndEmit
class DelegatingValTests : RegularValTests() { class DelegatingValTests : RegularValTests() {
override fun create(): ValAndEmit<*> { override fun create(): ObservableAndEmit<*, DelegatingVal<*>> {
var v = 0 var v = 0
val value = DelegatingVal({ v }, { v = it }) val value = DelegatingVal({ v }, { v = it })
return ValAndEmit(value) { value.value += 2 } return ObservableAndEmit(value) { value.value += 2 }
} }
override fun createBoolean(bool: Boolean): ValAndEmit<Boolean> { override fun <T> createWithValue(value: T): DelegatingVal<T> {
var v = bool var v = value
val value = DelegatingVal({ v }, { v = it }) return DelegatingVal({ v }, { v = it })
return ValAndEmit(value) { value.value = !value.value }
} }
} }

View File

@ -1,5 +1,6 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
import world.phantasmal.observable.ObservableAndEmit
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
@ -33,15 +34,14 @@ class FlatMappedValDependentValEmitsTests : RegularValTests() {
assertEquals(7, observedValue) assertEquals(7, observedValue)
} }
override fun create(): ValAndEmit<*> { override fun create(): ObservableAndEmit<*, FlatMappedVal<*>> {
val v = SimpleVal(SimpleVal(5)) val v = SimpleVal(SimpleVal(5))
val value = FlatMappedVal(listOf(v)) { v.value } val value = FlatMappedVal(listOf(v)) { v.value }
return ValAndEmit(value) { v.value = SimpleVal(v.value.value + 5) } return ObservableAndEmit(value) { v.value = SimpleVal(v.value.value + 5) }
} }
override fun createBoolean(bool: Boolean): ValAndEmit<Boolean> { override fun <T> createWithValue(value: T): FlatMappedVal<T> {
val v = SimpleVal(SimpleVal(bool)) val v = SimpleVal(SimpleVal(value))
val value = FlatMappedVal(listOf(v)) { v.value } return FlatMappedVal(listOf(v)) { v.value }
return ValAndEmit(value) { v.value = SimpleVal(!v.value.value) }
} }
} }

View File

@ -1,18 +1,19 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
import world.phantasmal.observable.ObservableAndEmit
/** /**
* In these tests the dependency of the [FlatMappedVal]'s direct dependency changes. * In these tests the dependency of the [FlatMappedVal]'s direct dependency changes.
*/ */
class FlatMappedValNestedValEmitsTests : RegularValTests() { class FlatMappedValNestedValEmitsTests : RegularValTests() {
override fun create(): ValAndEmit<*> { override fun create(): ObservableAndEmit<*, FlatMappedVal<*>> {
val v = SimpleVal(SimpleVal(5)) val v = SimpleVal(SimpleVal(5))
val value = FlatMappedVal(listOf(v)) { v.value } val value = FlatMappedVal(listOf(v)) { v.value }
return ValAndEmit(value) { v.value.value += 5 } return ObservableAndEmit(value) { v.value.value += 5 }
} }
override fun createBoolean(bool: Boolean): ValAndEmit<Boolean> { override fun <T> createWithValue(value: T): FlatMappedVal<T> {
val v = SimpleVal(SimpleVal(bool)) val v = SimpleVal(SimpleVal(value))
val value = FlatMappedVal(listOf(v)) { v.value } return FlatMappedVal(listOf(v)) { v.value }
return ValAndEmit(value) { v.value.value = !v.value.value }
} }
} }

View File

@ -1,15 +1,16 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
import world.phantasmal.observable.ObservableAndEmit
class MappedValTests : RegularValTests() { class MappedValTests : RegularValTests() {
override fun create(): ValAndEmit<*> { override fun create(): ObservableAndEmit<*, MappedVal<*>> {
val v = SimpleVal(0) val v = SimpleVal(0)
val value = MappedVal(listOf(v)) { 2 * v.value } val value = MappedVal(listOf(v)) { 2 * v.value }
return ValAndEmit(value) { v.value += 2 } return ObservableAndEmit(value) { v.value += 2 }
} }
override fun createBoolean(bool: Boolean): ValAndEmit<Boolean> { override fun <T> createWithValue(value: T): MappedVal<T> {
val v = SimpleVal(bool) val v = SimpleVal(value)
val value = MappedVal(listOf(v)) { v.value } return MappedVal(listOf(v)) { v.value }
return ValAndEmit(value) { v.value = !v.value }
} }
} }

View File

@ -10,12 +10,73 @@ import kotlin.test.assertTrue
* for every non-ListVal [Val] implementation. * for every non-ListVal [Val] implementation.
*/ */
abstract class RegularValTests : ValTests() { abstract class RegularValTests : ValTests() {
protected abstract fun createBoolean(bool: Boolean): ValAndEmit<Boolean> protected abstract fun <T> createWithValue(value: T): Val<T>
@Test
fun val_any_extensions() = test {
listOf(Any(), null).forEach { any ->
val value = createWithValue(any)
// Test the test setup first.
assertEquals(any, value.value)
// Test `isNull`.
assertEquals(any == null, value.isNull().value)
// Test `isNotNull`.
assertEquals(any != null, value.isNotNull().value)
}
listOf(10 to 10, 5 to 99, "a" to "a", "x" to "y").forEach { (a, b) ->
val aVal = createWithValue(a)
val bVal = createWithValue(b)
// Test the test setup first.
assertEquals(a, aVal.value)
assertEquals(b, bVal.value)
// Test `eq`.
assertEquals(a == b, (aVal eq b).value)
assertEquals(a == b, (aVal eq bVal).value)
// Test `ne`.
assertEquals(a != b, (aVal ne b).value)
assertEquals(a != b, (aVal ne bVal).value)
}
}
@Test
fun val_comparable_extensions() = test {
listOf(
10 to 10,
7.0 to 5.0,
(5000).toShort() to (7000).toShort()
).forEach { (a, b) ->
@Suppress("UNCHECKED_CAST")
a as Comparable<Any>
@Suppress("UNCHECKED_CAST")
b as Comparable<Any>
val aVal = createWithValue(a)
val bVal = createWithValue(b)
// Test the test setup first.
assertEquals(a, aVal.value)
assertEquals(b, bVal.value)
// Test `gt`.
assertEquals(a > b, (aVal gt b).value)
assertEquals(a > b, (aVal gt bVal).value)
// Test `lt`.
assertEquals(a < b, (aVal lt b).value)
assertEquals(a < b, (aVal lt bVal).value)
}
}
@Test @Test
fun val_boolean_extensions() = test { fun val_boolean_extensions() = test {
listOf(true, false).forEach { bool -> listOf(true, false).forEach { bool ->
val (value) = createBoolean(bool) val value = createWithValue(bool)
// Test the test setup first. // Test the test setup first.
assertEquals(bool, value.value) assertEquals(bool, value.value)

View File

@ -1,13 +1,13 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
import world.phantasmal.observable.ObservableAndEmit
class SimpleValTests : RegularValTests() { class SimpleValTests : RegularValTests() {
override fun create(): ValAndEmit<*> { override fun create(): ObservableAndEmit<*, SimpleVal<*>> {
val value = SimpleVal(1) val value = SimpleVal(1)
return ValAndEmit(value) { value.value += 2 } return ObservableAndEmit(value) { value.value += 2 }
} }
override fun createBoolean(bool: Boolean): ValAndEmit<Boolean> { override fun <T> createWithValue(value: T): SimpleVal<T> =
val value = SimpleVal(bool) SimpleVal(value)
return ValAndEmit(value) { value.value = !value.value }
}
} }

View File

@ -1,18 +1,18 @@
package world.phantasmal.observable.value package world.phantasmal.observable.value
import world.phantasmal.core.disposable.use import world.phantasmal.core.disposable.use
import world.phantasmal.observable.Observable
import world.phantasmal.observable.ObservableAndEmit
import world.phantasmal.observable.ObservableTests import world.phantasmal.observable.ObservableTests
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
typealias ValAndEmit<T> = Pair<Val<T>, () -> Unit>
/** /**
* Test suite for all [Val] implementations. There is a subclass of this suite for every [Val] * Test suite for all [Val] implementations. There is a subclass of this suite for every [Val]
* implementation. * implementation.
*/ */
abstract class ValTests : ObservableTests() { abstract class ValTests : ObservableTests() {
abstract override fun create(): ValAndEmit<*> abstract override fun create(): ObservableAndEmit<*, Val<*>>
/** /**
* When [Val.observe] is called with callNow = true, it should call the observer immediately. * When [Val.observe] is called with callNow = true, it should call the observer immediately.

View File

@ -1,7 +1,7 @@
package world.phantasmal.observable.value.list package world.phantasmal.observable.value.list
class DependentListValTests : ListValTests() { class DependentListValTests : ListValTests() {
override fun create(): ListValAndAdd { override fun create(): ListValAndAdd<*, DependentListVal<*>> {
val l = SimpleListVal<Int>(mutableListOf()) val l = SimpleListVal<Int>(mutableListOf())
val list = DependentListVal(listOf(l)) { l.value.map { 2 * it } } val list = DependentListVal(listOf(l)) { l.value.map { 2 * it } }
return ListValAndAdd(list) { l.add(4) } return ListValAndAdd(list) { l.add(4) }

View File

@ -1,17 +1,21 @@
package world.phantasmal.observable.value.list package world.phantasmal.observable.value.list
import world.phantasmal.observable.ObservableAndEmit
import world.phantasmal.observable.value.ValTests import world.phantasmal.observable.value.ValTests
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
typealias ListValAndAdd = Pair<ListVal<*>, () -> Unit> class ListValAndAdd<T, out O : ListVal<T>>(
observable: O,
add: () -> Unit,
) : ObservableAndEmit<List<T>, O>(observable, add)
/** /**
* Test suite for all [ListVal] implementations. There is a subclass of this suite for every * Test suite for all [ListVal] implementations. There is a subclass of this suite for every
* [ListVal] implementation. * [ListVal] implementation.
*/ */
abstract class ListValTests : ValTests() { abstract class ListValTests : ValTests() {
abstract override fun create(): ListValAndAdd abstract override fun create(): ListValAndAdd<*, ListVal<*>>
@Test @Test
fun listVal_updates_sizeVal_correctly() = test { fun listVal_updates_sizeVal_correctly() = test {

View File

@ -1,7 +1,7 @@
package world.phantasmal.observable.value.list package world.phantasmal.observable.value.list
class SimpleListValTests : ListValTests() { class SimpleListValTests : ListValTests() {
override fun create(): ListValAndAdd { override fun create(): ListValAndAdd<*, SimpleListVal<*>> {
val value = SimpleListVal(mutableListOf<Int>()) val value = SimpleListVal(mutableListOf<Int>())
return ListValAndAdd(value) { value.add(7) } return ListValAndAdd(value) { value.add(7) }
} }

View File

@ -50,7 +50,7 @@ class Application(
// The various tools Phantasmal World consists of. // The various tools Phantasmal World consists of.
val tools: List<PwTool> = listOf( val tools: List<PwTool> = listOf(
Viewer(createEngine), Viewer(createEngine),
QuestEditor(assetLoader, createEngine), QuestEditor(assetLoader, uiStore, createEngine),
HuntOptimizer(assetLoader, uiStore), HuntOptimizer(assetLoader, uiStore),
) )

View File

@ -2,9 +2,7 @@ package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.not
import world.phantasmal.web.application.controllers.MainContentController import world.phantasmal.web.application.controllers.MainContentController
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.LazyLoader import world.phantasmal.webui.widgets.LazyLoader
@ -21,7 +19,7 @@ class MainContentWidget(
ctrl.tools.forEach { (tool, active) -> ctrl.tools.forEach { (tool, active) ->
toolViews[tool]?.let { createWidget -> toolViews[tool]?.let { createWidget ->
addChild(LazyLoader(scope, hidden = !active, createWidget = createWidget)) addChild(LazyLoader(scope, visible = active, createWidget = createWidget))
} }
} }
} }

View File

@ -2,7 +2,8 @@ package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.value
import world.phantasmal.web.application.controllers.NavigationController import world.phantasmal.web.application.controllers.NavigationController
import world.phantasmal.web.core.dom.externalLink import world.phantasmal.web.core.dom.externalLink
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
@ -31,11 +32,11 @@ class NavigationWidget(
val serverSelect = Select( val serverSelect = Select(
scope, scope,
disabled = trueVal(), enabled = falseVal(),
label = "Server:", label = "Server:",
items = listOf("Ephinea"), items = listOf("Ephinea"),
selected = "Ephinea", selected = "Ephinea",
tooltip = "Only Ephinea is supported at the moment", tooltip = value("Only Ephinea is supported at the moment"),
) )
addWidget(serverSelect.label!!) addWidget(serverSelect.label!!)
addChild(serverSelect) addChild(serverSelect)

View File

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

View File

@ -30,10 +30,10 @@ open class PathAwareTabController<T : PathAwareTab>(
super.setActiveTab(tab) super.setActiveTab(tab)
} }
override fun hiddenChanged(hidden: Boolean) { override fun visibleChanged(visible: Boolean) {
super.hiddenChanged(hidden) super.visibleChanged(visible)
if (!hidden && uiStore.currentTool.value == tool) { if (visible && uiStore.currentTool.value == tool) {
activeTab.value?.let { activeTab.value?.let {
uiStore.setPathPrefix(it.path, replace = true) uiStore.setPathPrefix(it.path, replace = true)
} }

View File

@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import world.phantasmal.observable.value.MutableVal import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.eq
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.models.Server import world.phantasmal.web.core.models.Server
@ -76,7 +77,7 @@ class UiStore(
toolToActive = tools toolToActive = tools
.map { tool -> .map { tool ->
tool to currentTool.map { it == tool } tool to (currentTool eq tool)
} }
.toMap() .toMap()

View File

@ -0,0 +1,27 @@
package world.phantasmal.web.core.undo
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.actions.Action
interface Undo {
val canUndo: Val<Boolean>
val canRedo: Val<Boolean>
/**
* The first action that will be undone when calling undo().
*/
val firstUndo: Val<Action?>
/**
* The first action that will be redone when calling redo().
*/
val firstRedo: Val<Action?>
/**
* Ensures this undo is the current undo in its [UndoManager].
*/
fun makeCurrent()
fun undo(): Boolean
fun redo(): Boolean
fun reset()
}

View File

@ -0,0 +1,51 @@
package world.phantasmal.web.core.undo
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.nullVal
import world.phantasmal.web.core.actions.Action
class UndoManager {
private val _current = mutableVal<Undo>(NopUndo)
val current: Val<Undo> = _current
val canUndo: Val<Boolean> = current.flatMap { it.canUndo }
val canRedo: Val<Boolean> = current.flatMap { it.canRedo }
val firstUndo: Val<Action?> = current.flatMap { it.firstUndo }
val firstRedo: Val<Action?> = current.flatMap { it.firstRedo }
fun setCurrent(undo: Undo) {
_current.value = undo
}
fun undo(): Boolean =
current.value.undo()
fun redo(): Boolean =
current.value.redo()
fun makeNopCurrent() {
setCurrent(NopUndo)
}
private object NopUndo : Undo {
override val canUndo = falseVal()
override val canRedo = falseVal()
override val firstUndo = nullVal()
override val firstRedo = nullVal()
override fun makeCurrent() {
// Do nothing.
}
override fun undo(): Boolean = false
override fun redo(): Boolean = false
override fun reset() {
// Do nothing.
}
}
}

View File

@ -0,0 +1,73 @@
package world.phantasmal.web.core.undo
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.gt
import world.phantasmal.observable.value.list.mutableListVal
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.actions.Action
/**
* Full-fledged linear undo/redo implementation.
*/
class UndoStack(private val manager: UndoManager) : Undo {
private val stack = mutableListVal<Action>()
/**
* 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].
*/
private val index = mutableVal(0)
private var undoingOrRedoing = false
override val canUndo: Val<Boolean> = index gt 0
override val canRedo: Val<Boolean> = stack.map(index) { stack, index -> index < stack.size }
override val firstUndo: Val<Action?> = index.map { stack.value.getOrNull(it - 1) }
override val firstRedo: Val<Action?> = index.map { stack.value.getOrNull(it) }
override fun makeCurrent() {
manager.setCurrent(this)
}
fun push(action: Action): Action {
if (!undoingOrRedoing) {
stack.splice(index.value, stack.value.size - index.value, action)
index.value++
}
return action
}
override fun undo(): Boolean {
if (undoingOrRedoing || !canUndo.value) return false
try {
undoingOrRedoing = true
index.value -= 1
stack[index.value].undo()
} finally {
undoingOrRedoing = false
return true
}
}
override fun redo(): Boolean {
if (undoingOrRedoing || !canRedo.value) return false
try {
undoingOrRedoing = true
stack[index.value].execute()
index.value += 1
} finally {
undoingOrRedoing = false
return true
}
}
override fun reset() {
stack.clear()
index.value = 0
}
}

View File

@ -3,10 +3,10 @@ package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.obj
import world.phantasmal.web.externals.goldenLayout.GoldenLayout import world.phantasmal.web.externals.goldenLayout.GoldenLayout
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.obj
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
private const val HEADER_HEIGHT = 24 private const val HEADER_HEIGHT = 24
@ -44,9 +44,9 @@ class DockedWidget(
class DockWidget( class DockWidget(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
private val item: DockedItem, private val item: DockedItem,
) : Widget(scope, hidden) { ) : Widget(scope, visible) {
private lateinit var goldenLayout: GoldenLayout private lateinit var goldenLayout: GoldenLayout
init { init {

View File

@ -21,11 +21,11 @@ class RendererWidget(
observeResize() observeResize()
observe(selfOrAncestorHidden) { hidden -> observe(selfOrAncestorVisible) { visible ->
if (hidden) { if (visible) {
renderer.stopRendering()
} else {
renderer.startRendering() renderer.startRendering()
} else {
renderer.stopRendering()
} }
} }

View File

@ -3,21 +3,21 @@ package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.trueVal import world.phantasmal.observable.value.falseVal
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Label import world.phantasmal.webui.widgets.Label
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
class UnavailableWidget( class UnavailableWidget(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean>, visible: Val<Boolean>,
private val message: String, private val message: String,
) : Widget(scope, hidden) { ) : Widget(scope, visible) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-core-unavailable" className = "pw-core-unavailable"
addWidget(Label(scope, disabled = trueVal(), text = message)) addWidget(Label(scope, enabled = falseVal(), text = message))
} }
companion object { companion object {

View File

@ -6,6 +6,7 @@ import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.questEditor.controllers.NpcCountsController import world.phantasmal.web.questEditor.controllers.NpcCountsController
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
@ -13,9 +14,9 @@ import world.phantasmal.web.questEditor.controllers.QuestInfoController
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.rendering.UserInputManager
import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager
import world.phantasmal.web.questEditor.rendering.QuestRenderer import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.questEditor.rendering.UserInputManager
import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.widgets.* import world.phantasmal.web.questEditor.widgets.*
@ -24,6 +25,7 @@ import world.phantasmal.webui.widgets.Widget
class QuestEditor( class QuestEditor(
private val assetLoader: AssetLoader, private val assetLoader: AssetLoader,
private val uiStore: UiStore,
private val createEngine: (HTMLCanvasElement) -> Engine, private val createEngine: (HTMLCanvasElement) -> Engine,
) : DisposableContainer(), PwTool { ) : DisposableContainer(), PwTool {
override val toolType = PwToolType.QuestEditor override val toolType = PwToolType.QuestEditor
@ -40,11 +42,14 @@ class QuestEditor(
// Stores // Stores
val areaStore = addDisposable(AreaStore(scope, areaAssetLoader)) val areaStore = addDisposable(AreaStore(scope, areaAssetLoader))
val questEditorStore = addDisposable(QuestEditorStore(scope, areaStore)) val questEditorStore = addDisposable(QuestEditorStore(scope, uiStore, areaStore))
// Controllers // Controllers
val toolbarController = val toolbarController = addDisposable(QuestEditorToolbarController(
addDisposable(QuestEditorToolbarController(questLoader, areaStore, questEditorStore)) questLoader,
areaStore,
questEditorStore,
))
val questInfoController = addDisposable(QuestInfoController(questEditorStore)) val questInfoController = addDisposable(QuestInfoController(questEditorStore))
val npcCountsController = addDisposable(NpcCountsController(questEditorStore)) val npcCountsController = addDisposable(NpcCountsController(questEditorStore))

View File

@ -0,0 +1,12 @@
package world.phantasmal.web.questEditor
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
/**
* Orchestrates everything related to emulating a quest run. Drives a [VirtualMachine] and
* delegates to [Debugger].
*/
class QuestRunner {
val running: Val<Boolean> = falseVal()
}

View File

@ -0,0 +1,42 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel
class TranslateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val entity: QuestEntityModel<*, *>,
private val oldSection: SectionModel?,
private val newSection: SectionModel?,
private val oldPosition: Vector3,
private val newPosition: Vector3,
private val world: Boolean,
) : Action {
override val description: String = "Move ${entity.type.simpleName}"
override fun execute() {
setSelectedEntity(entity)
newSection?.let(entity::setSection)
if (world) {
entity.setWorldPosition(newPosition)
} else {
entity.setPosition(newPosition)
}
}
override fun undo() {
setSelectedEntity(entity)
oldSection?.let(entity::setSection)
if (world) {
entity.setWorldPosition(oldPosition)
} else {
entity.setPosition(oldPosition)
}
}
}

View File

@ -2,13 +2,14 @@ package world.phantasmal.web.questEditor.controllers
import world.phantasmal.lib.fileFormats.quest.NpcType import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNull
import world.phantasmal.observable.value.list.emptyListVal import world.phantasmal.observable.value.list.emptyListVal
import world.phantasmal.web.questEditor.models.QuestNpcModel import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller import world.phantasmal.webui.controllers.Controller
class NpcCountsController(store: QuestEditorStore) : Controller() { class NpcCountsController(store: QuestEditorStore) : Controller() {
val unavailable: Val<Boolean> = store.currentQuest.map { it == null } val unavailable: Val<Boolean> = store.currentQuest.isNull()
val npcCounts: Val<List<NameWithCount>> = store.currentQuest val npcCounts: Val<List<NameWithCount>> = store.currentQuest
.flatMap { it?.npcs ?: emptyListVal() } .flatMap { it?.npcs ?: emptyListVal() }

View File

@ -9,9 +9,8 @@ import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.Quest import world.phantasmal.lib.fileFormats.quest.Quest
import world.phantasmal.lib.fileFormats.quest.parseBinDatToQuest import world.phantasmal.lib.fileFormats.quest.parseBinDatToQuest
import world.phantasmal.lib.fileFormats.quest.parseQstToQuest import world.phantasmal.lib.fileFormats.quest.parseQstToQuest
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.mutableVal import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.observable.value.value
import world.phantasmal.web.questEditor.loading.QuestLoader import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.models.AreaModel import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.stores.AreaStore import world.phantasmal.web.questEditor.stores.AreaStore
@ -32,11 +31,31 @@ class QuestEditorToolbarController(
private val _resultDialogVisible = mutableVal(false) private val _resultDialogVisible = mutableVal(false)
private val _result = mutableVal<PwResult<*>?>(null) private val _result = mutableVal<PwResult<*>?>(null)
// Result
val resultDialogVisible: Val<Boolean> = _resultDialogVisible val resultDialogVisible: Val<Boolean> = _resultDialogVisible
val result: Val<PwResult<*>?> = _result val result: Val<PwResult<*>?> = _result
// Ensure the areas list is updated when entities are added or removed (the count in the // Undo
// label should update).
val undoTooltip: Val<String> = questEditorStore.firstUndo.map { action ->
(action?.let { "Undo \"${action.description}\"" } ?: "Nothing to undo") + " (Ctrl-Z)"
}
val undoEnabled: Val<Boolean> = questEditorStore.canUndo
// Redo
val redoTooltip: Val<String> = questEditorStore.firstRedo.map { action ->
(action?.let { "Redo \"${action.description}\"" } ?: "Nothing to redo") + " (Ctrl-Shift-Z)"
}
val redoEnabled: Val<Boolean> = questEditorStore.canRedo
// Areas
// Ensure the areas list is updated when entities are added or removed (the count in the label
// should update).
val areas: Val<List<AreaAndLabel>> = questEditorStore.currentQuest.flatMap { quest -> val areas: Val<List<AreaAndLabel>> = questEditorStore.currentQuest.flatMap { quest ->
quest?.let { quest?.let {
quest.entitiesPerArea.map { entitiesPerArea -> quest.entitiesPerArea.map { entitiesPerArea ->
@ -47,15 +66,12 @@ class QuestEditorToolbarController(
} }
} ?: value(emptyList()) } ?: value(emptyList())
} }
val currentArea: Val<AreaAndLabel?> = areas.map(questEditorStore.currentArea) { areas, area -> val currentArea: Val<AreaAndLabel?> = areas.map(questEditorStore.currentArea) { areas, area ->
areas.find { it.area == area } areas.find { it.area == area }
} }
val areaSelectDisabled: Val<Boolean>
init { val areaSelectEnabled: Val<Boolean> = questEditorStore.currentQuest.isNotNull()
val noQuestLoaded = questEditorStore.currentQuest.map { it == null }
areaSelectDisabled = noQuestLoaded
}
suspend fun createNewQuest(episode: Episode) { suspend fun createNewQuest(episode: Episode) {
questEditorStore.setCurrentQuest( questEditorStore.setCurrentQuest(
@ -107,6 +123,14 @@ class QuestEditorToolbarController(
} }
} }
fun undo() {
questEditorStore.undo()
}
fun redo() {
questEditorStore.redo()
}
fun setCurrentArea(areaAndLabel: AreaAndLabel) { fun setCurrentArea(areaAndLabel: AreaAndLabel) {
questEditorStore.setCurrentArea(areaAndLabel.area) questEditorStore.setCurrentArea(areaAndLabel.area)
} }

View File

@ -1,13 +1,14 @@
package world.phantasmal.web.questEditor.controllers package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNull
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
import world.phantasmal.web.questEditor.stores.QuestEditorStore import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller import world.phantasmal.webui.controllers.Controller
class QuestInfoController(store: QuestEditorStore) : Controller() { class QuestInfoController(store: QuestEditorStore) : Controller() {
val unavailable: Val<Boolean> = store.currentQuest.map { it == null } val unavailable: Val<Boolean> = store.currentQuest.isNull()
val disabled: Val<Boolean> = store.questEditingDisabled val enabled: Val<Boolean> = store.questEditingEnabled
val episode: Val<String> = store.currentQuest.map { it?.episode?.name ?: "" } val episode: Val<String> = store.currentQuest.map { it?.episode?.name ?: "" }
val id: Val<Int> = store.currentQuest.flatMap { it?.id ?: value(0) } val id: Val<Int> = store.currentQuest.flatMap { it?.id ?: value(0) }

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mu.KotlinLogging import mu.KotlinLogging
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNotNull
import world.phantasmal.web.externals.babylon.AbstractMesh import world.phantasmal.web.externals.babylon.AbstractMesh
import world.phantasmal.web.externals.babylon.TransformNode import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.loading.EntityAssetLoader import world.phantasmal.web.questEditor.loading.EntityAssetLoader
@ -162,7 +163,7 @@ class EntityMeshManager(
sectionInitialized && (sWave == null || sWave == entityWave) sectionInitialized && (sWave == null || sWave == entityWave)
} }
} else { } else {
isVisible = entity.section.map { section -> section != null } isVisible = entity.section.isNotNull()
if (entity is QuestObjectModel) { if (entity is QuestObjectModel) {
addDisposable(entity.model.observe(callNow = false) { addDisposable(entity.model.observe(callNow = false) {

View File

@ -157,6 +157,24 @@ private class StateContext(
} }
} }
fun finalizeTranslation(
entity: QuestEntityModel<*, *>,
oldSection: SectionModel?,
newSection: SectionModel?,
oldPosition: Vector3,
newPosition: Vector3,
world: Boolean,
) {
questEditorStore.translateEntity(
entity,
oldSection,
newSection,
oldPosition,
newPosition,
world
)
}
/** /**
* If the drag-adjusted pointer is over the ground, translate an entity horizontally across the * If the drag-adjusted pointer is over the ground, translate an entity horizontally across the
* ground. Otherwise translate the entity over the horizontal plane that intersects its origin. * ground. Otherwise translate the entity over the horizontal plane that intersects its origin.
@ -426,22 +444,14 @@ private class TranslationState(
ctx.renderer.enableCameraControls() ctx.renderer.enableCameraControls()
if (!cancelled && event.movedSinceLastPointerDown) { if (!cancelled && event.movedSinceLastPointerDown) {
// TODO ctx.finalizeTranslation(
// questEditorStore.undo entity,
// .push( initialSection,
// new TranslateEntityAction ( entity.section.value,
// this.questEditorStore, initialPosition,
// this.entity, entity.worldPosition.value,
// this.initialSection, true,
// this.entity.section. )
// val ,
// this.initial_position,
// this.entity.world_position.
// val ,
// true,
// ),
// )
// .redo()
} }
IdleState(ctx, entityManipulationEnabled = true) IdleState(ctx, entityManipulationEnabled = true)

View File

@ -2,28 +2,75 @@ package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging import mu.KotlinLogging
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.mutableVal import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.core.undo.UndoStack
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.questEditor.QuestRunner
import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.* import world.phantasmal.web.questEditor.models.*
import world.phantasmal.webui.stores.Store import world.phantasmal.webui.stores.Store
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class QuestEditorStore(scope: CoroutineScope, private val areaStore: AreaStore) : Store(scope) { class QuestEditorStore(
scope: CoroutineScope,
private val uiStore: UiStore,
private val areaStore: AreaStore,
) : Store(scope) {
private val _currentQuest = mutableVal<QuestModel?>(null) private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null) private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null) private val _selectedWave = mutableVal<WaveModel?>(null)
private val _selectedEntity = mutableVal<QuestEntityModel<*, *>?>(null) private val _selectedEntity = mutableVal<QuestEntityModel<*, *>?>(null)
private val undoManager = UndoManager()
private val mainUndo = UndoStack(undoManager)
val runner = QuestRunner()
val currentQuest: Val<QuestModel?> = _currentQuest val currentQuest: Val<QuestModel?> = _currentQuest
val currentArea: Val<AreaModel?> = _currentArea val currentArea: Val<AreaModel?> = _currentArea
val selectedWave: Val<WaveModel?> = _selectedWave val selectedWave: Val<WaveModel?> = _selectedWave
val selectedEntity: Val<QuestEntityModel<*, *>?> = _selectedEntity val selectedEntity: Val<QuestEntityModel<*, *>?> = _selectedEntity
// TODO: Take into account whether we're debugging or not. val questEditingEnabled: Val<Boolean> = currentQuest.isNotNull() and !runner.running
val questEditingDisabled: Val<Boolean> = currentQuest.map { it == null } val canUndo: Val<Boolean> = questEditingEnabled and undoManager.canUndo
val firstUndo: Val<Action?> = undoManager.firstUndo
val canRedo: Val<Boolean> = questEditingEnabled and undoManager.canRedo
val firstRedo: Val<Action?> = undoManager.firstRedo
init {
observe(uiStore.currentTool) { tool ->
if (tool == PwToolType.QuestEditor) {
mainUndo.makeCurrent()
}
}
}
fun makeMainUndoCurrent() {
mainUndo.makeCurrent()
}
fun undo() {
require(canUndo.value) { "Can't undo at the moment." }
undoManager.undo()
}
fun redo() {
require(canRedo.value) { "Can't redo at the moment." }
undoManager.redo()
}
suspend fun setCurrentQuest(quest: QuestModel?) { suspend fun setCurrentQuest(quest: QuestModel?) {
mainUndo.reset()
// TODO: Stop runner.
_selectedEntity.value = null
_selectedWave.value = null
if (quest == null) { if (quest == null) {
_currentArea.value = null _currentArea.value = null
_currentQuest.value = null _currentQuest.value = null
@ -80,4 +127,23 @@ class QuestEditorStore(scope: CoroutineScope, private val areaStore: AreaStore)
_selectedEntity.value = entity _selectedEntity.value = entity
} }
fun translateEntity(
entity: QuestEntityModel<*, *>,
oldSection: SectionModel?,
newSection: SectionModel?,
oldPosition: Vector3,
newPosition: Vector3,
world: Boolean,
) {
mainUndo.push(TranslateEntityAction(
::setSelectedEntity,
entity,
oldSection,
newSection,
oldPosition,
newPosition,
world,
)).execute()
}
} }

View File

@ -2,7 +2,6 @@ package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.not
import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.core.widgets.UnavailableWidget
import world.phantasmal.web.questEditor.controllers.NpcCountsController import world.phantasmal.web.questEditor.controllers.NpcCountsController
import world.phantasmal.webui.dom.* import world.phantasmal.webui.dom.*
@ -28,7 +27,7 @@ class NpcCountsWidget(
} }
addChild(UnavailableWidget( addChild(UnavailableWidget(
scope, scope,
hidden = !ctrl.unavailable, visible = ctrl.unavailable,
message = "No quest loaded." message = "No quest loaded."
)) ))
} }

View File

@ -24,7 +24,7 @@ class QuestEditorToolbarWidget(
scope, scope,
text = "New quest", text = "New quest",
iconLeft = Icon.NewFile, iconLeft = Icon.NewFile,
onClick = { scope.launch { ctrl.createNewQuest(Episode.I) } } onClick = { scope.launch { ctrl.createNewQuest(Episode.I) } },
), ),
FileButton( FileButton(
scope, scope,
@ -32,15 +32,31 @@ class QuestEditorToolbarWidget(
iconLeft = Icon.File, iconLeft = Icon.File,
accept = ".bin, .dat, .qst", accept = ".bin, .dat, .qst",
multiple = true, multiple = true,
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } } filesSelected = { files -> scope.launch { ctrl.openFiles(files) } },
),
Button(
scope,
text = "Undo",
iconLeft = Icon.Undo,
enabled = ctrl.undoEnabled,
tooltip = ctrl.undoTooltip,
onClick = { ctrl.undo() },
),
Button(
scope,
text = "Redo",
iconLeft = Icon.Redo,
enabled = ctrl.redoEnabled,
tooltip = ctrl.redoTooltip,
onClick = { ctrl.redo() },
), ),
Select( Select(
scope, scope,
disabled = ctrl.areaSelectDisabled, enabled = ctrl.areaSelectEnabled,
itemsVal = ctrl.areas, itemsVal = ctrl.areas,
itemToString = { it.label }, itemToString = { it.label },
selectedVal = ctrl.currentArea, selectedVal = ctrl.currentArea,
onSelect = ctrl::setCurrentArea onSelect = ctrl::setCurrentArea,
) )
) )
)) ))

View File

@ -2,7 +2,6 @@ package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.not
import world.phantasmal.web.core.widgets.UnavailableWidget import world.phantasmal.web.core.widgets.UnavailableWidget
import world.phantasmal.web.questEditor.controllers.QuestInfoController import world.phantasmal.web.questEditor.controllers.QuestInfoController
import world.phantasmal.webui.dom.* import world.phantasmal.webui.dom.*
@ -14,7 +13,7 @@ import world.phantasmal.webui.widgets.Widget
class QuestInfoWidget( class QuestInfoWidget(
scope: CoroutineScope, scope: CoroutineScope,
private val ctrl: QuestInfoController, private val ctrl: QuestInfoController,
) : Widget(scope, disabled = ctrl.disabled) { ) : Widget(scope, enabled = ctrl.enabled) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-quest-info" className = "pw-quest-editor-quest-info"
@ -32,7 +31,7 @@ class QuestInfoWidget(
td { td {
addChild(IntInput( addChild(IntInput(
this@QuestInfoWidget.scope, this@QuestInfoWidget.scope,
disabled = ctrl.disabled, enabled = ctrl.enabled,
valueVal = ctrl.id, valueVal = ctrl.id,
min = 0, min = 0,
step = 1, step = 1,
@ -44,7 +43,7 @@ class QuestInfoWidget(
td { td {
addChild(TextInput( addChild(TextInput(
this@QuestInfoWidget.scope, this@QuestInfoWidget.scope,
disabled = ctrl.disabled, enabled = ctrl.enabled,
valueVal = ctrl.name, valueVal = ctrl.name,
maxLength = 32, maxLength = 32,
)) ))
@ -61,7 +60,7 @@ class QuestInfoWidget(
colSpan = 2 colSpan = 2
addChild(TextArea( addChild(TextArea(
this@QuestInfoWidget.scope, this@QuestInfoWidget.scope,
disabled = ctrl.disabled, enabled = ctrl.enabled,
valueVal = ctrl.shortDescription, valueVal = ctrl.shortDescription,
maxLength = 128, maxLength = 128,
fontFamily = "\"Courier New\", monospace", fontFamily = "\"Courier New\", monospace",
@ -81,7 +80,7 @@ class QuestInfoWidget(
colSpan = 2 colSpan = 2
addChild(TextArea( addChild(TextArea(
this@QuestInfoWidget.scope, this@QuestInfoWidget.scope,
disabled = ctrl.disabled, enabled = ctrl.enabled,
valueVal = ctrl.longDescription, valueVal = ctrl.longDescription,
maxLength = 288, maxLength = 288,
fontFamily = "\"Courier New\", monospace", fontFamily = "\"Courier New\", monospace",
@ -93,7 +92,7 @@ class QuestInfoWidget(
} }
addChild(UnavailableWidget( addChild(UnavailableWidget(
scope, scope,
hidden = !ctrl.unavailable, visible = ctrl.unavailable,
message = "No quest loaded." message = "No quest loaded."
)) ))
} }

View File

@ -8,7 +8,7 @@ class QuestEditorTests : WebTestSuite() {
@Test @Test
fun initialization_and_shutdown_should_succeed_without_throwing() = test { fun initialization_and_shutdown_should_succeed_without_throwing() = test {
val questEditor = disposer.add( val questEditor = disposer.add(
QuestEditor(components.assetLoader, createEngine = { Engine(it) }) QuestEditor(components.assetLoader, components.uiStore, createEngine = { Engine(it) })
) )
disposer.add(questEditor.initialize(scope)) disposer.add(questEditor.initialize(scope))
} }

View File

@ -12,7 +12,7 @@ import kotlin.test.assertTrue
class NpcCountsControllerTests : WebTestSuite() { class NpcCountsControllerTests : WebTestSuite() {
@Test @Test
fun exposes_correct_model_before_and_after_a_quest_is_loaded() = test { fun exposes_correct_model_before_and_after_a_quest_is_loaded() = asyncTest {
val store = components.questEditorStore val store = components.questEditorStore
val ctrl = disposer.add(NpcCountsController(store)) val ctrl = disposer.add(NpcCountsController(store))

View File

@ -3,11 +3,13 @@ package world.phantasmal.web.questEditor.controllers
import org.w3c.files.File import org.w3c.files.File
import world.phantasmal.core.Failure import world.phantasmal.core.Failure
import world.phantasmal.core.Severity import world.phantasmal.core.Severity
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.test.WebTestSuite import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test import world.phantasmal.web.test.createQuestModel
import kotlin.test.assertEquals import world.phantasmal.web.test.createQuestNpcModel
import kotlin.test.assertNull import kotlin.test.*
import kotlin.test.assertTrue
class QuestEditorToolbarControllerTests : WebTestSuite() { class QuestEditorToolbarControllerTests : WebTestSuite() {
@Test @Test
@ -15,7 +17,7 @@ class QuestEditorToolbarControllerTests : WebTestSuite() {
val ctrl = disposer.add(QuestEditorToolbarController( val ctrl = disposer.add(QuestEditorToolbarController(
components.questLoader, components.questLoader,
components.areaStore, components.areaStore,
components.questEditorStore components.questEditorStore,
)) ))
assertNull(ctrl.result.value) assertNull(ctrl.result.value)
@ -29,7 +31,84 @@ class QuestEditorToolbarControllerTests : WebTestSuite() {
assertEquals(Severity.Error, result.problems.first().severity) assertEquals(Severity.Error, result.problems.first().severity)
assertEquals( assertEquals(
"Please select a .qst file or one .bin and one .dat file.", "Please select a .qst file or one .bin and one .dat file.",
result.problems.first().uiMessage result.problems.first().uiMessage,
) )
} }
@Test
fun undo_state_changes_correctly() = asyncTest {
val ctrl = disposer.add(QuestEditorToolbarController(
components.questLoader,
components.areaStore,
components.questEditorStore,
))
components.questEditorStore.makeMainUndoCurrent()
val nothingToUndo = "Nothing to undo (Ctrl-Z)"
val nothingToRedo = "Nothing to redo (Ctrl-Shift-Z)"
// No quest loaded.
assertEquals(nothingToUndo, ctrl.undoTooltip.value)
assertFalse(ctrl.undoEnabled.value)
assertEquals(nothingToRedo, ctrl.redoTooltip.value)
assertFalse(ctrl.redoEnabled.value)
// Load quest.
val npc = createQuestNpcModel(NpcType.Scientist, Episode.I)
components.questEditorStore.setCurrentQuest(createQuestModel(npcs= listOf(npc)))
assertEquals(nothingToUndo, ctrl.undoTooltip.value)
assertFalse(ctrl.undoEnabled.value)
assertEquals(nothingToRedo, ctrl.redoTooltip.value)
assertFalse(ctrl.redoEnabled.value)
// Add an action to the undo stack.
components.questEditorStore.translateEntity(
npc,
null,
null,
Vector3.Zero(),
Vector3.Up(),
true,
)
assertEquals("Undo \"Move Scientist\" (Ctrl-Z)", ctrl.undoTooltip.value)
assertTrue(ctrl.undoEnabled.value)
assertEquals(nothingToRedo, ctrl.redoTooltip.value)
assertFalse(ctrl.redoEnabled.value)
// Undo the previous action.
ctrl.undo()
assertEquals(nothingToUndo, ctrl.undoTooltip.value)
assertFalse(ctrl.undoEnabled.value)
assertEquals("Redo \"Move Scientist\" (Ctrl-Shift-Z)", ctrl.redoTooltip.value)
assertTrue(ctrl.redoEnabled.value)
}
@Test
fun area_state_changes_correctly() = asyncTest {
val ctrl = disposer.add(QuestEditorToolbarController(
components.questLoader,
components.areaStore,
components.questEditorStore,
))
// No quest loaded.
assertTrue(ctrl.areas.value.isEmpty())
assertNull(ctrl.currentArea.value)
assertFalse(ctrl.areaSelectEnabled.value)
// Load quest.
components.questEditorStore.setCurrentQuest(createQuestModel())
assertTrue(ctrl.areas.value.isNotEmpty())
assertNotNull(ctrl.currentArea.value)
assertTrue(ctrl.areaSelectEnabled.value)
}
} }

View File

@ -10,12 +10,12 @@ import kotlin.test.assertTrue
class QuestInfoControllerTests : WebTestSuite() { class QuestInfoControllerTests : WebTestSuite() {
@Test @Test
fun exposes_correct_model_before_and_after_a_quest_is_loaded() = test { fun exposes_correct_model_before_and_after_a_quest_is_loaded() = asyncTest {
val store = components.questEditorStore val store = components.questEditorStore
val ctrl = disposer.add(QuestInfoController(store)) val ctrl = disposer.add(QuestInfoController(store))
assertTrue(ctrl.unavailable.value) assertTrue(ctrl.unavailable.value)
assertTrue(ctrl.disabled.value) assertFalse(ctrl.enabled.value)
store.setCurrentQuest(createQuestModel( store.setCurrentQuest(createQuestModel(
id = 25, id = 25,
@ -26,7 +26,7 @@ class QuestInfoControllerTests : WebTestSuite() {
)) ))
assertFalse(ctrl.unavailable.value) assertFalse(ctrl.unavailable.value)
assertFalse(ctrl.disabled.value) assertTrue(ctrl.enabled.value)
assertEquals("II", ctrl.episode.value) assertEquals("II", ctrl.episode.value)
assertEquals(25, ctrl.id.value) assertEquals(25, ctrl.id.value)
assertEquals("A Quest", ctrl.name.value) assertEquals("A Quest", ctrl.name.value)

View File

@ -0,0 +1,115 @@
package world.phantasmal.web.questEditor.undo
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.core.undo.UndoStack
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UndoStackTests : WebTestSuite() {
@Test
fun simple_properties_and_invariants() {
val stack = UndoStack(UndoManager())
assertFalse(stack.canUndo.value)
assertFalse(stack.canRedo.value)
stack.push(DummyAction())
stack.push(DummyAction())
stack.push(DummyAction())
assertTrue(stack.canUndo.value)
assertFalse(stack.canRedo.value)
stack.undo()
assertTrue(stack.canUndo.value)
assertTrue(stack.canRedo.value)
stack.undo()
stack.undo()
assertFalse(stack.canUndo.value)
assertTrue(stack.canRedo.value)
}
@Test
fun undo() {
val stack = UndoStack(UndoManager())
var value = 3
stack.push(DummyAction(execute = { value = 7 }, undo = { value = 3 })).execute()
stack.push(DummyAction(execute = { value = 13 }, undo = { value = 7 })).execute()
assertEquals(13, value)
assertTrue(stack.undo())
assertEquals(7, value)
assertTrue(stack.undo())
assertEquals(3, value)
assertFalse(stack.undo())
assertEquals(3, value)
}
@Test
fun redo() {
val stack = UndoStack(UndoManager())
var value = 3
stack.push(DummyAction(execute = { value = 7 }, undo = { value = 3 })).execute()
stack.push(DummyAction(execute = { value = 13 }, undo = { value = 7 })).execute()
stack.undo()
stack.undo()
assertEquals(3, value)
assertTrue(stack.redo())
assertEquals(7, value)
assertTrue(stack.redo())
assertEquals(13, value)
assertFalse(stack.redo())
assertEquals(13, value)
}
@Test
fun push_then_undo_then_push_again() {
val stack = UndoStack(UndoManager())
var value = 3
stack.push(DummyAction(execute = { value = 7 }, undo = { value = 3 })).execute()
stack.undo()
assertEquals(3, value)
stack.push(DummyAction(execute = { value = 13 }, undo = { value = 7 })).execute()
assertEquals(13, value)
}
private class DummyAction(
private val execute: () -> Unit = {},
private val undo: () -> Unit = {},
) : Action {
override val description: String = "Dummy action"
override fun execute() {
execute.invoke()
}
override fun undo() {
undo.invoke()
}
}
}

View File

@ -8,6 +8,8 @@ import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
import world.phantasmal.testUtils.TestContext import world.phantasmal.testUtils.TestContext
import world.phantasmal.web.core.loading.AssetLoader import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.externals.babylon.Scene import world.phantasmal.web.externals.babylon.Scene
import world.phantasmal.web.questEditor.loading.AreaAssetLoader import world.phantasmal.web.questEditor.loading.AreaAssetLoader
@ -33,6 +35,8 @@ class TestComponents(private val ctx: TestContext) {
} }
} }
var applicationUrl: ApplicationUrl by default { TestApplicationUrl("") }
// Babylon.js // Babylon.js
var scene: Scene by default { Scene(Engine(null)) } var scene: Scene by default { Scene(Engine(null)) }
@ -49,10 +53,12 @@ class TestComponents(private val ctx: TestContext) {
// Stores // Stores
var uiStore: UiStore by default { UiStore(ctx.scope, applicationUrl) }
var areaStore: AreaStore by default { AreaStore(ctx.scope, areaAssetLoader) } var areaStore: AreaStore by default { AreaStore(ctx.scope, areaAssetLoader) }
var questEditorStore: QuestEditorStore by default { var questEditorStore: QuestEditorStore by default {
QuestEditorStore(ctx.scope, areaStore) QuestEditorStore(ctx.scope, uiStore, areaStore)
} }
private fun <T> default(defaultValue: () -> T) = LazyDefault { private fun <T> default(defaultValue: () -> T) = LazyDefault {

View File

@ -17,5 +17,5 @@ open class TabController<T : Tab>(val tabs: List<T>) : Controller() {
_activeTab.value = tab _activeTab.value = tab
} }
open fun hiddenChanged(hidden: Boolean) {} open fun visibleChanged(visible: Boolean) {}
} }

View File

@ -5,7 +5,8 @@ import org.w3c.dom.Node
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.MouseEvent
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.button import world.phantasmal.webui.dom.button
import world.phantasmal.webui.dom.icon import world.phantasmal.webui.dom.icon
@ -13,8 +14,9 @@ import world.phantasmal.webui.dom.span
open class Button( open class Button(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(),
private val text: String? = null, private val text: String? = null,
private val textVal: Val<String>? = null, private val textVal: Val<String>? = null,
private val iconLeft: Icon? = null, private val iconLeft: Icon? = null,
@ -25,7 +27,7 @@ open class Button(
private val onKeyDown: ((KeyboardEvent) -> Unit)? = null, private val onKeyDown: ((KeyboardEvent) -> Unit)? = null,
private val onKeyUp: ((KeyboardEvent) -> Unit)? = null, private val onKeyUp: ((KeyboardEvent) -> Unit)? = null,
private val onKeyPress: ((KeyboardEvent) -> Unit)? = null, private val onKeyPress: ((KeyboardEvent) -> Unit)? = null,
) : Control(scope, hidden, disabled) { ) : Control(scope, visible, enabled, tooltip) {
override fun Node.createElement() = override fun Node.createElement() =
button { button {
className = "pw-button" className = "pw-button"

View File

@ -2,7 +2,8 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
/** /**
* Represents all widgets that allow for user interaction such as buttons, text inputs, combo boxes, * Represents all widgets that allow for user interaction such as buttons, text inputs, combo boxes,
@ -10,7 +11,7 @@ import world.phantasmal.observable.value.falseVal
*/ */
abstract class Control( abstract class Control(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
) : Widget(scope, hidden, disabled, tooltip) ) : Widget(scope, visible, enabled, tooltip)

View File

@ -3,15 +3,16 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.round import kotlin.math.round
class DoubleInput( class DoubleInput(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
label: String? = null, label: String? = null,
labelVal: Val<String>? = null, labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before, preferredLabelPosition: LabelPosition = LabelPosition.Before,
@ -21,8 +22,8 @@ class DoubleInput(
roundTo: Int = 2, roundTo: Int = 2,
) : NumberInput<Double>( ) : NumberInput<Double>(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,

View File

@ -5,13 +5,16 @@ import org.w3c.dom.HTMLElement
import org.w3c.files.File import org.w3c.files.File
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.openFiles import world.phantasmal.webui.openFiles
class FileButton( class FileButton(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(),
text: String? = null, text: String? = null,
textVal: Val<String>? = null, textVal: Val<String>? = null,
iconLeft: Icon? = null, iconLeft: Icon? = null,
@ -19,7 +22,7 @@ class FileButton(
private val accept: String = "", private val accept: String = "",
private val multiple: Boolean = false, private val multiple: Boolean = false,
private val filesSelected: ((List<File>) -> Unit)? = null, private val filesSelected: ((List<File>) -> Unit)? = null,
) : Button(scope, hidden, disabled, text, textVal, iconLeft, iconRight) { ) : Button(scope, visible, enabled, tooltip, text, textVal, iconLeft, iconRight) {
override fun interceptElement(element: HTMLElement) { override fun interceptElement(element: HTMLElement) {
element.classList.add("pw-file-button") element.classList.add("pw-file-button")

View File

@ -9,9 +9,9 @@ import world.phantasmal.webui.dom.span
abstract class Input<T>( abstract class Input<T>(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean>, visible: Val<Boolean>,
disabled: Val<Boolean>, enabled: Val<Boolean>,
tooltip: String?, tooltip: Val<String?>,
label: String?, label: String?,
labelVal: Val<String>?, labelVal: Val<String>?,
preferredLabelPosition: LabelPosition, preferredLabelPosition: LabelPosition,
@ -27,8 +27,8 @@ abstract class Input<T>(
private val step: Int?, private val step: Int?,
) : LabelledControl( ) : LabelledControl(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,
@ -42,7 +42,7 @@ abstract class Input<T>(
classList.add("pw-input-inner", inputClassName) classList.add("pw-input-inner", inputClassName)
type = inputType type = inputType
observe(this@Input.disabled) { disabled = it } observe(this@Input.enabled) { disabled = !it }
onchange = { onChange(getInputValue(this)) } onchange = { onChange(getInputValue(this)) }

View File

@ -3,13 +3,14 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
class IntInput( class IntInput(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
label: String? = null, label: String? = null,
labelVal: Val<String>? = null, labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before, preferredLabelPosition: LabelPosition = LabelPosition.Before,
@ -21,8 +22,8 @@ class IntInput(
step: Int? = null, step: Int? = null,
) : NumberInput<Int>( ) : NumberInput<Int>(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,

View File

@ -3,17 +3,17 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.label import world.phantasmal.webui.dom.label
class Label( class Label(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
private val text: String? = null, private val text: String? = null,
private val textVal: Val<String>? = null, private val textVal: Val<String>? = null,
private val htmlFor: String? = null, private val htmlFor: String? = null,
) : Widget(scope, hidden, disabled) { ) : Widget(scope, visible, enabled) {
override fun Node.createElement() = override fun Node.createElement() =
label { label {
className = "pw-label" className = "pw-label"
@ -26,7 +26,7 @@ class Label(
} }
} }
companion object{ companion object {
init { init {
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty") @Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
// language=css // language=css

View File

@ -10,13 +10,13 @@ enum class LabelPosition {
abstract class LabelledControl( abstract class LabelledControl(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean>, visible: Val<Boolean>,
disabled: Val<Boolean>, enabled: Val<Boolean>,
tooltip: String? = null, tooltip: Val<String?>,
label: String?, label: String?,
labelVal: Val<String>?, labelVal: Val<String>?,
val preferredLabelPosition: LabelPosition, val preferredLabelPosition: LabelPosition,
) : Control(scope, hidden, disabled, tooltip) { ) : Control(scope, visible, enabled, tooltip) {
val label: Label? by lazy { val label: Label? by lazy {
if (label == null && labelVal == null) { if (label == null && labelVal == null) {
null null
@ -28,7 +28,7 @@ abstract class LabelledControl(
element.id = id element.id = id
} }
Label(scope, hidden, disabled, label, labelVal, htmlFor = id) Label(scope, visible, enabled, label, labelVal, htmlFor = id)
} }
} }

View File

@ -3,23 +3,23 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
class LazyLoader( class LazyLoader(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
private val createWidget: (CoroutineScope) -> Widget, private val createWidget: (CoroutineScope) -> Widget,
) : Widget(scope, hidden, disabled) { ) : Widget(scope, visible, enabled) {
private var initialized = false private var initialized = false
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-lazy-loader" className = "pw-lazy-loader"
observe(this@LazyLoader.hidden) { h -> observe(this@LazyLoader.visible) { v ->
if (!h && !initialized) { if (v && !initialized) {
initialized = true initialized = true
addChild(createWidget(scope)) addChild(createWidget(scope))
} }

View File

@ -8,7 +8,8 @@ import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.MouseEvent
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
import world.phantasmal.webui.dom.disposableListener import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
@ -16,9 +17,9 @@ import world.phantasmal.webui.obj
class Menu<T : Any>( class Menu<T : Any>(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
items: List<T>? = null, items: List<T>? = null,
itemsVal: Val<List<T>>? = null, itemsVal: Val<List<T>>? = null,
private val itemToString: (T) -> String = Any::toString, private val itemToString: (T) -> String = Any::toString,
@ -26,8 +27,8 @@ class Menu<T : Any>(
private val onCancel: () -> Unit = {}, private val onCancel: () -> Unit = {},
) : Widget( ) : Widget(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
) { ) {
private val items: Val<List<T>> = itemsVal ?: value(items ?: emptyList()) private val items: Val<List<T>> = itemsVal ?: value(items ?: emptyList())
@ -57,21 +58,21 @@ class Menu<T : Any>(
} }
} }
observe(this@Menu.hidden) { observe(this@Menu.visible) {
if (it) { if (it) {
onDocumentMouseDownListener =
disposableListener(document, "mousedown", ::onDocumentMouseDown)
} else {
onDocumentMouseDownListener?.dispose() onDocumentMouseDownListener?.dispose()
onDocumentMouseDownListener = null onDocumentMouseDownListener = null
clearHighlightItem() clearHighlightItem()
(previouslyFocusedElement as HTMLElement?)?.focus() (previouslyFocusedElement as HTMLElement?)?.focus()
} else {
onDocumentMouseDownListener =
disposableListener(document, "mousedown", ::onDocumentMouseDown)
} }
} }
observe(disabled) { observe(enabled) {
if (it) { if (!it) {
clearHighlightItem() clearHighlightItem()
} }
} }
@ -170,7 +171,7 @@ class Menu<T : Any>(
private fun highlightItemAt(index: Int) { private fun highlightItemAt(index: Int) {
highlightedElement?.classList?.remove("pw-menu-highlighted") highlightedElement?.classList?.remove("pw-menu-highlighted")
if (disabled.value) return if (!enabled.value) return
highlightedElement = innerElement.children.item(index) highlightedElement = innerElement.children.item(index)
@ -182,7 +183,7 @@ class Menu<T : Any>(
} }
private fun selectItem(index: Int) { private fun selectItem(index: Int) {
if (disabled.value) return if (!enabled.value) return
items.value.getOrNull(index)?.let(onSelect) items.value.getOrNull(index)?.let(onSelect)
} }

View File

@ -5,9 +5,9 @@ import world.phantasmal.observable.value.Val
abstract class NumberInput<T : Number>( abstract class NumberInput<T : Number>(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean>, visible: Val<Boolean>,
disabled: Val<Boolean>, enabled: Val<Boolean>,
tooltip: String?, tooltip: Val<String?>,
label: String?, label: String?,
labelVal: Val<String>?, labelVal: Val<String>?,
preferredLabelPosition: LabelPosition, preferredLabelPosition: LabelPosition,
@ -19,8 +19,8 @@ abstract class NumberInput<T : Number>(
step: Int?, step: Int?,
) : Input<T>( ) : Input<T>(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,

View File

@ -4,18 +4,15 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import org.w3c.dom.events.KeyboardEvent import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent import org.w3c.dom.events.MouseEvent
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.value
import world.phantasmal.webui.dom.Icon import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
class Select<T : Any>( class Select<T : Any>(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
label: String? = null, label: String? = null,
labelVal: Val<String>? = null, labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before, preferredLabelPosition: LabelPosition = LabelPosition.Before,
@ -27,8 +24,8 @@ class Select<T : Any>(
private val onSelect: (T) -> Unit = {}, private val onSelect: (T) -> Unit = {},
) : LabelledControl( ) : LabelledControl(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,
@ -38,7 +35,7 @@ class Select<T : Any>(
private val selected: Val<T?> = selectedVal ?: value(selected) private val selected: Val<T?> = selectedVal ?: value(selected)
private val buttonText = mutableVal(" ") private val buttonText = mutableVal(" ")
private val menuHidden = mutableVal(true) private val menuVisible = mutableVal(false)
private lateinit var menu: Menu<T> private lateinit var menu: Menu<T>
private var justOpened = false private var justOpened = false
@ -52,7 +49,7 @@ class Select<T : Any>(
addWidget(Button( addWidget(Button(
scope, scope,
disabled = disabled, enabled = enabled,
textVal = buttonText, textVal = buttonText,
iconRight = Icon.TriangleDown, iconRight = Icon.TriangleDown,
onMouseDown = ::onButtonMouseDown, onMouseDown = ::onButtonMouseDown,
@ -61,19 +58,19 @@ class Select<T : Any>(
)) ))
menu = addWidget(Menu( menu = addWidget(Menu(
scope, scope,
hidden = menuHidden, visible = menuVisible,
disabled = disabled, enabled = enabled,
itemsVal = items, itemsVal = items,
itemToString = itemToString, itemToString = itemToString,
onSelect = ::select, onSelect = ::select,
onCancel = { menuHidden.value = true }, onCancel = { menuVisible.value = false },
)) ))
} }
private fun onButtonMouseDown(e: MouseEvent) { private fun onButtonMouseDown(e: MouseEvent) {
e.stopPropagation() e.stopPropagation()
justOpened = menuHidden.value justOpened = !menuVisible.value
menuHidden.value = false menuVisible.value = true
selected.value?.let(menu::highlightItem) selected.value?.let(menu::highlightItem)
} }
@ -81,7 +78,7 @@ class Select<T : Any>(
if (justOpened) { if (justOpened) {
menu.focus() menu.focus()
} else { } else {
menuHidden.value = true menuVisible.value = false
} }
justOpened = false justOpened = false
@ -93,8 +90,8 @@ class Select<T : Any>(
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
justOpened = menuHidden.value justOpened = !menuVisible.value
menuHidden.value = false menuVisible.value = true
selected.value?.let(menu::highlightItem) selected.value?.let(menu::highlightItem)
menu.focus() menu.focus()
} }
@ -134,7 +131,7 @@ class Select<T : Any>(
} }
private fun select(item: T) { private fun select(item: T) {
menuHidden.value = true menuVisible.value = false
buttonText.value = itemToString(item) buttonText.value = itemToString(item)
onSelect(item) onSelect(item)
} }

View File

@ -3,7 +3,8 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.eq
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.controllers.Tab import world.phantasmal.webui.controllers.Tab
import world.phantasmal.webui.controllers.TabController import world.phantasmal.webui.controllers.TabController
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
@ -11,11 +12,11 @@ import world.phantasmal.webui.dom.span
class TabContainer<T : Tab>( class TabContainer<T : Tab>(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
private val ctrl: TabController<T>, private val ctrl: TabController<T>,
private val createWidget: (CoroutineScope, T) -> Widget, private val createWidget: (CoroutineScope, T) -> Widget,
) : Widget(scope, hidden, disabled) { ) : Widget(scope, visible, enabled) {
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-tab-container" className = "pw-tab-container"
@ -48,7 +49,7 @@ class TabContainer<T : Tab>(
addChild( addChild(
LazyLoader( LazyLoader(
scope, scope,
hidden = ctrl.activeTab.map { it != tab }, visible = ctrl.activeTab eq tab,
createWidget = { scope -> createWidget(scope, tab) } createWidget = { scope -> createWidget(scope, tab) }
) )
) )
@ -57,7 +58,7 @@ class TabContainer<T : Tab>(
} }
init { init {
observe(selfOrAncestorHidden, ctrl::hiddenChanged) observe(selfOrAncestorVisible, ctrl::visibleChanged)
} }
companion object { companion object {

View File

@ -3,15 +3,16 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.textarea import world.phantasmal.webui.dom.textarea
class TextArea( class TextArea(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
label: String? = null, label: String? = null,
labelVal: Val<String>? = null, labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before, preferredLabelPosition: LabelPosition = LabelPosition.Before,
@ -24,8 +25,8 @@ class TextArea(
private val cols: Int? = null, private val cols: Int? = null,
) : LabelledControl( ) : LabelledControl(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,
@ -38,7 +39,7 @@ class TextArea(
textarea { textarea {
className = "pw-text-area-inner" className = "pw-text-area-inner"
observe(this@TextArea.disabled) { disabled = it } observe(this@TextArea.enabled) { disabled = !it }
if (setValue != null) { if (setValue != null) {
onchange = { setValue.invoke(value) } onchange = { setValue.invoke(value) }

View File

@ -3,13 +3,14 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
class TextInput( class TextInput(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
tooltip: String? = null, tooltip: Val<String?> = nullVal(),
label: String? = null, label: String? = null,
labelVal: Val<String>? = null, labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before, preferredLabelPosition: LabelPosition = LabelPosition.Before,
@ -19,8 +20,8 @@ class TextInput(
maxLength: Int? = null, maxLength: Int? = null,
) : Input<String>( ) : Input<String>(
scope, scope,
hidden, visible,
disabled, enabled,
tooltip, tooltip,
label, label,
labelVal, labelVal,

View File

@ -3,15 +3,15 @@ package world.phantasmal.webui.widgets
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node import org.w3c.dom.Node
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
class Toolbar( class Toolbar(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
disabled: Val<Boolean> = falseVal(), enabled: Val<Boolean> = trueVal(),
children: List<Widget>, children: List<Widget>,
) : Widget(scope, hidden, disabled) { ) : Widget(scope, visible, enabled) {
private val childWidgets = children private val childWidgets = children
override fun Node.createElement() = override fun Node.createElement() =

View File

@ -5,12 +5,9 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.* import org.w3c.dom.*
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.Observable import world.phantasmal.observable.Observable
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.list.ListVal import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.ListValChangeEvent import world.phantasmal.observable.value.list.ListValChangeEvent
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.or
import world.phantasmal.webui.DisposableContainer import world.phantasmal.webui.DisposableContainer
abstract class Widget( abstract class Widget(
@ -18,15 +15,15 @@ abstract class Widget(
/** /**
* By default determines the hidden attribute of its [element]. * By default determines the hidden attribute of its [element].
*/ */
val hidden: Val<Boolean> = falseVal(), val visible: Val<Boolean> = trueVal(),
/** /**
* By default determines the disabled attribute of its [element] and whether or not the * By default determines the disabled attribute of its [element] and whether or not the
* "pw-disabled" class is added. * "pw-disabled" class is added.
*/ */
val disabled: Val<Boolean> = falseVal(), val enabled: Val<Boolean> = trueVal(),
val tooltip: String? = null, val tooltip: Val<String?> = nullVal(),
) : DisposableContainer() { ) : DisposableContainer() {
private val _ancestorHidden = mutableVal(false) private val _ancestorVisible = mutableVal(true)
private val _children = mutableListOf<Widget>() private val _children = mutableListOf<Widget>()
private var initResizeObserverRequested = false private var initResizeObserverRequested = false
private var resizeObserverInitialized = false private var resizeObserverInitialized = false
@ -34,22 +31,28 @@ abstract class Widget(
private val elementDelegate = lazy { private val elementDelegate = lazy {
val el = document.createDocumentFragment().createElement() val el = document.createDocumentFragment().createElement()
observe(hidden) { hidden -> observe(visible) { visible ->
el.hidden = hidden el.hidden = !visible
children.forEach { setAncestorHidden(it, hidden || ancestorHidden.value) } children.forEach { setAncestorVisible(it, visible && ancestorVisible.value) }
} }
observe(disabled) { disabled -> observe(enabled) { enabled ->
if (disabled) { if (enabled) {
el.setAttribute("disabled", "")
el.classList.add("pw-disabled")
} else {
el.removeAttribute("disabled") el.removeAttribute("disabled")
el.classList.remove("pw-disabled") el.classList.remove("pw-disabled")
} else {
el.setAttribute("disabled", "")
el.classList.add("pw-disabled")
} }
} }
tooltip?.let { el.title = it } observe(tooltip) { tooltip ->
if (tooltip == null) {
el.removeAttribute("title")
} else {
el.title = tooltip
}
}
if (initResizeObserverRequested) { if (initResizeObserverRequested) {
initResizeObserver(el) initResizeObserver(el)
@ -65,14 +68,14 @@ abstract class Widget(
val element: HTMLElement by elementDelegate val element: HTMLElement by elementDelegate
/** /**
* True if any of this widget's ancestors are [hidden], false otherwise. * True if this widget's ancestors are [visible], false otherwise.
*/ */
val ancestorHidden: Val<Boolean> = _ancestorHidden val ancestorVisible: Val<Boolean> = _ancestorVisible
/** /**
* True if this widget or any of its ancestors are [hidden], false otherwise. * True if this widget and all of its ancestors are [visible], false otherwise.
*/ */
val selfOrAncestorHidden: Val<Boolean> = hidden or ancestorHidden val selfOrAncestorVisible: Val<Boolean> = visible and ancestorVisible
val children: List<Widget> = _children val children: List<Widget> = _children
@ -122,7 +125,7 @@ abstract class Widget(
protected fun <T : Widget> Node.addChild(child: T): T { protected fun <T : Widget> Node.addChild(child: T): T {
addDisposable(child) addDisposable(child)
_children.add(child) _children.add(child)
setAncestorHidden(child, selfOrAncestorHidden.value) setAncestorVisible(child, selfOrAncestorVisible.value)
appendChild(child.element) appendChild(child.element)
return child return child
} }
@ -239,13 +242,13 @@ abstract class Widget(
STYLE_EL.append(style) STYLE_EL.append(style)
} }
protected fun setAncestorHidden(widget: Widget, hidden: Boolean) { protected fun setAncestorVisible(widget: Widget, visible: Boolean) {
widget._ancestorHidden.value = hidden widget._ancestorVisible.value = visible
if (widget.hidden.value) return if (!widget.visible.value) return
widget.children.forEach { widget.children.forEach {
setAncestorHidden(it, widget.selfOrAncestorHidden.value) setAncestorVisible(it, widget.selfOrAncestorVisible.value)
} }
} }
} }

View File

@ -14,59 +14,59 @@ import kotlin.test.assertTrue
class WidgetTests : WebuiTestSuite() { class WidgetTests : WebuiTestSuite() {
@Test @Test
fun ancestorHidden_and_selfOrAncestorHidden_should_update_when_hidden_changes() = test { fun ancestorVisible_and_selfOrAncestorVisible_should_update_when_visible_changes() = test {
val parentHidden = mutableVal(false) val parentVisible = mutableVal(true)
val childHidden = mutableVal(false) val childVisible = mutableVal(true)
val grandChild = DummyWidget(scope) val grandChild = DummyWidget(scope)
val child = DummyWidget(scope, childHidden, grandChild) val child = DummyWidget(scope, childVisible, grandChild)
val parent = disposer.add(DummyWidget(scope, parentHidden, child)) val parent = disposer.add(DummyWidget(scope, parentVisible, child))
parent.element // Ensure widgets are fully initialized. parent.element // Ensure widgets are fully initialized.
assertFalse(parent.ancestorHidden.value) assertTrue(parent.ancestorVisible.value)
assertFalse(parent.selfOrAncestorHidden.value) assertTrue(parent.selfOrAncestorVisible.value)
assertFalse(child.ancestorHidden.value) assertTrue(child.ancestorVisible.value)
assertFalse(child.selfOrAncestorHidden.value) assertTrue(child.selfOrAncestorVisible.value)
assertFalse(grandChild.ancestorHidden.value) assertTrue(grandChild.ancestorVisible.value)
assertFalse(grandChild.selfOrAncestorHidden.value) assertTrue(grandChild.selfOrAncestorVisible.value)
parentHidden.value = true parentVisible.value = false
assertFalse(parent.ancestorHidden.value) assertTrue(parent.ancestorVisible.value)
assertTrue(parent.selfOrAncestorHidden.value) assertFalse(parent.selfOrAncestorVisible.value)
assertTrue(child.ancestorHidden.value) assertFalse(child.ancestorVisible.value)
assertTrue(child.selfOrAncestorHidden.value) assertFalse(child.selfOrAncestorVisible.value)
assertTrue(grandChild.ancestorHidden.value) assertFalse(grandChild.ancestorVisible.value)
assertTrue(grandChild.selfOrAncestorHidden.value) assertFalse(grandChild.selfOrAncestorVisible.value)
childHidden.value = true childVisible.value = false
parentHidden.value = false parentVisible.value = true
assertFalse(parent.ancestorHidden.value) assertTrue(parent.ancestorVisible.value)
assertFalse(parent.selfOrAncestorHidden.value) assertTrue(parent.selfOrAncestorVisible.value)
assertFalse(child.ancestorHidden.value) assertTrue(child.ancestorVisible.value)
assertTrue(child.selfOrAncestorHidden.value) assertFalse(child.selfOrAncestorVisible.value)
assertTrue(grandChild.ancestorHidden.value) assertFalse(grandChild.ancestorVisible.value)
assertTrue(grandChild.selfOrAncestorHidden.value) assertFalse(grandChild.selfOrAncestorVisible.value)
} }
@Test @Test
fun added_child_widgets_should_have_ancestorHidden_and_selfOrAncestorHidden_set_correctly() = fun added_child_widgets_should_have_ancestorVisible_and_selfOrAncestorVisible_set_correctly() =
test { test {
val parent = disposer.add(DummyWidget(scope, hidden = trueVal())) val parent = disposer.add(DummyWidget(scope, visible = falseVal()))
val child = parent.addChild(DummyWidget(scope)) val child = parent.addChild(DummyWidget(scope))
assertFalse(parent.ancestorHidden.value) assertTrue(parent.ancestorVisible.value)
assertTrue(parent.selfOrAncestorHidden.value) assertFalse(parent.selfOrAncestorVisible.value)
assertTrue(child.ancestorHidden.value) assertFalse(child.ancestorVisible.value)
assertTrue(child.selfOrAncestorHidden.value) assertFalse(child.selfOrAncestorVisible.value)
} }
private inner class DummyWidget( private inner class DummyWidget(
scope: CoroutineScope, scope: CoroutineScope,
hidden: Val<Boolean> = falseVal(), visible: Val<Boolean> = trueVal(),
private val child: Widget? = null, private val child: Widget? = null,
) : Widget(scope, hidden = hidden) { ) : Widget(scope, visible = visible) {
override fun Node.createElement() = div { override fun Node.createElement() = div {
child?.let { addChild(it) } child?.let { addChild(it) }
} }