Hunt method tables now show correct data again and times can be edited and persisted again.

This commit is contained in:
Daan Vanden Bosch 2020-12-09 22:03:10 +01:00
parent 41f8e53efc
commit c3927729ad
39 changed files with 660 additions and 307 deletions

View File

@ -18,6 +18,13 @@ class Disposer(vararg disposables: Disposable) : TrackedDisposable() {
return disposable
}
fun <T : Disposable> add(index: Int, disposable: T): T {
require(!disposed) { "Disposer already disposed." }
disposables.add(index, disposable)
return disposable
}
/**
* Add 0 or more disposables.
*/

View File

@ -31,7 +31,7 @@ abstract class TrackedDisposable : Disposable {
}
companion object {
const val DISPOSABLE_PRINT_COUNT = 10
private const val DISPOSABLE_PRINT_COUNT = 10
var disposables: MutableSet<Disposable> = mutableSetOf()
var trackPrecise = false
@ -47,29 +47,31 @@ abstract class TrackedDisposable : Disposable {
try {
block()
checkLeaks(disposableCount - initialCount)
checkLeakCountZero(disposableCount - initialCount)
} finally {
this.trackPrecise = initialTrackPrecise
disposables = initialDisposables
}
}
fun checkLeaks(leakCount: Int) {
buildString {
append("$leakCount TrackedDisposables were leaked")
fun checkLeakCountZero(leakCount: Int) {
check(leakCount == 0) {
buildString {
append("$leakCount TrackedDisposables were leaked")
if (trackPrecise) {
append(": ")
disposables.take(DISPOSABLE_PRINT_COUNT).joinTo(this) {
it::class.simpleName ?: "Anonymous"
if (trackPrecise) {
append(": ")
disposables.take(DISPOSABLE_PRINT_COUNT).joinTo(this) {
it::class.simpleName ?: "Anonymous"
}
if (disposables.size > DISPOSABLE_PRINT_COUNT) {
append(",..")
}
}
if (disposables.size > DISPOSABLE_PRINT_COUNT) {
append(",..")
}
append(".")
}
append(".")
}
}
}

View File

@ -20,4 +20,6 @@ interface MutableListVal<E> : ListVal<E>, MutableVal<List<E>> {
fun splice(from: Int, removeCount: Int, newElement: E)
fun clear()
fun sortWith(comparator: Comparator<E>)
}

View File

@ -88,6 +88,11 @@ class SimpleListVal<E>(
finalizeUpdate(ListValChangeEvent.Change(0, removed, emptyList()))
}
override fun sortWith(comparator: Comparator<E>) {
elements.sortWith(comparator)
finalizeUpdate(ListValChangeEvent.Change(0, elements, elements))
}
override fun finalizeUpdate(event: ListValChangeEvent<E>) {
_sizeVal.value = elements.size
super.finalizeUpdate(event)

View File

@ -49,9 +49,9 @@ class Application(
// The various tools Phantasmal World consists of.
val tools: List<PwTool> = listOf(
Viewer(createThreeRenderer),
QuestEditor(assetLoader, uiStore, createThreeRenderer),
HuntOptimizer(assetLoader, uiStore),
addDisposable(Viewer(createThreeRenderer)),
addDisposable(QuestEditor(assetLoader, uiStore, createThreeRenderer)),
addDisposable(HuntOptimizer(assetLoader, uiStore)),
)
// Controllers.
@ -61,11 +61,13 @@ class Application(
// Initialize application view.
val applicationWidget = addDisposable(
ApplicationWidget(
NavigationWidget(navigationController),
MainContentWidget(
mainContentController,
tools.map { it.toolType to it::initialize }.toMap()
)
{ NavigationWidget(navigationController) },
{
MainContentWidget(
mainContentController,
tools.map { it.toolType to it::initialize }.toMap()
)
}
)
)

View File

@ -5,15 +5,15 @@ import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
class ApplicationWidget(
private val navigationWidget: NavigationWidget,
private val mainContentWidget: MainContentWidget,
private val createNavigationWidget: () -> NavigationWidget,
private val createMainContentWidget: () -> MainContentWidget,
) : Widget() {
override fun Node.createElement() =
div {
className = "pw-application-application"
addChild(navigationWidget)
addChild(mainContentWidget)
addChild(createNavigationWidget())
addChild(createMainContentWidget())
}
companion object {

View File

@ -35,7 +35,7 @@ class NavigationWidget(private val ctrl: NavigationController) : Widget() {
selected = value("Ephinea"),
tooltip = value("Only Ephinea is supported at the moment"),
)
addWidget(serverSelect.label!!)
addChild(serverSelect.label!!)
addChild(serverSelect)
span {

View File

@ -11,7 +11,7 @@ import world.phantasmal.webui.widgets.Control
class PwToolButton(
private val tool: PwToolType,
private val toggled: Observable<Boolean>,
private val mouseDown: () -> Unit,
private val onMouseDown: () -> Unit,
) : Control() {
private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}"
@ -28,7 +28,7 @@ class PwToolButton(
label {
htmlFor = inputId
textContent = tool.uiName
onmousedown = { mouseDown() }
onmousedown = { onMouseDown() }
}
}

View File

@ -3,15 +3,15 @@ package world.phantasmal.web.core.controllers
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Tab
import world.phantasmal.webui.controllers.TabController
import world.phantasmal.webui.controllers.TabContainerController
open class PathAwareTab(override val title: String, val path: String) : Tab
open class PathAwareTabController<T : PathAwareTab>(
open class PathAwareTabContainerController<T : PathAwareTab>(
private val uiStore: UiStore,
private val tool: PwToolType,
tabs: List<T>,
) : TabController<T>(tabs) {
) : TabContainerController<T>(tabs) {
init {
observe(uiStore.path) { path ->
if (uiStore.currentTool.value == tool) {

View File

@ -19,6 +19,8 @@ abstract class Persister {
persist(key, data, serializer())
}
// Method suspends so we can use async storage in the future.
@Suppress("RedundantSuspendModifier")
protected suspend fun <T> persist(key: String, data: T, serializer: KSerializer<T>) {
try {
localStorage.setItem(key, format.encodeToString(serializer, data))
@ -27,13 +29,19 @@ abstract class Persister {
}
}
protected suspend fun persistForServer(server: Server, key: String, data: Any) {
protected suspend inline fun <reified T> persistForServer(
server: Server,
key: String,
data: T,
) {
persist(serverKey(server, key), data)
}
protected suspend inline fun <reified T> load(key: String): T? =
load(key, serializer())
// Method suspends so we can use async storage in the future.
@Suppress("RedundantSuspendModifier")
protected suspend fun <T> load(key: String, serializer: KSerializer<T>): T? =
try {
val json = localStorage.getItem(key)

View File

@ -7,6 +7,7 @@ import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.controllers.HuntOptimizerController
import world.phantasmal.web.huntOptimizer.controllers.MethodsController
import world.phantasmal.web.huntOptimizer.controllers.MethodsForEpisodeController
import world.phantasmal.web.huntOptimizer.persistence.HuntMethodPersister
import world.phantasmal.web.huntOptimizer.stores.HuntMethodStore
import world.phantasmal.web.huntOptimizer.widgets.HuntOptimizerWidget
import world.phantasmal.web.huntOptimizer.widgets.MethodsForEpisodeWidget
@ -21,8 +22,12 @@ class HuntOptimizer(
override val toolType = PwToolType.HuntOptimizer
override fun initialize(): Widget {
// Persistence
val huntMethodPersister = HuntMethodPersister()
// Stores
val huntMethodStore = addDisposable(HuntMethodStore(uiStore, assetLoader))
val huntMethodStore =
addDisposable(HuntMethodStore(uiStore, assetLoader, huntMethodPersister))
// Controllers
val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore))

View File

@ -2,12 +2,12 @@ package world.phantasmal.web.huntOptimizer.controllers
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.controllers.PathAwareTab
import world.phantasmal.web.core.controllers.PathAwareTabController
import world.phantasmal.web.core.controllers.PathAwareTabContainerController
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls
class HuntOptimizerController(uiStore: UiStore) :
PathAwareTabController<PathAwareTab>(
PathAwareTabContainerController<PathAwareTab>(
uiStore,
PwToolType.HuntOptimizer,
listOf(

View File

@ -3,13 +3,13 @@ package world.phantasmal.web.huntOptimizer.controllers
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.controllers.PathAwareTab
import world.phantasmal.web.core.controllers.PathAwareTabController
import world.phantasmal.web.core.controllers.PathAwareTabContainerController
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls
class MethodsTab(title: String, path: String, val episode: Episode) : PathAwareTab(title, path)
class MethodsController(uiStore: UiStore) : PathAwareTabController<MethodsTab>(
class MethodsController(uiStore: UiStore) : PathAwareTabContainerController<MethodsTab>(
uiStore,
PwToolType.HuntOptimizer,
listOf(

View File

@ -3,16 +3,84 @@ package world.phantasmal.web.huntOptimizer.controllers
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.mutableListVal
import world.phantasmal.web.huntOptimizer.models.HuntMethodModel
import world.phantasmal.web.huntOptimizer.stores.HuntMethodStore
import world.phantasmal.webui.controllers.Controller
import world.phantasmal.webui.controllers.Column
import world.phantasmal.webui.controllers.SortColumn
import world.phantasmal.webui.controllers.SortDirection
import world.phantasmal.webui.controllers.TableController
import kotlin.time.Duration
class MethodsForEpisodeController(
huntMethodStore: HuntMethodStore,
private val huntMethodStore: HuntMethodStore,
episode: Episode,
) : Controller() {
val enemies: List<NpcType> = NpcType.VALUES.filter { it.enemy && it.episode == episode }
) : TableController<HuntMethodModel>() {
private val methods = mutableListVal<HuntMethodModel>()
private val enemies: List<NpcType> = NpcType.VALUES.filter { it.enemy && it.episode == episode }
val methods: ListVal<HuntMethodModel> =
huntMethodStore.methods.filtered { it.episode == episode }
override val values: ListVal<HuntMethodModel> = methods
override val columns: List<Column<HuntMethodModel>> = listOf(
Column(
key = METHOD_COL_KEY,
title = "Method",
fixed = true,
width = 250,
sortable = true,
),
Column(
key = TIME_COL_KEY,
title = "Time",
fixed = true,
width = 60,
input = true,
sortable = true,
),
*enemies.map { enemy ->
Column<HuntMethodModel>(
key = enemy.name,
title = enemy.simpleName,
width = 90,
textAlign = "right",
sortable = true,
)
}.toTypedArray()
)
init {
observe(huntMethodStore.methods) { allMethods ->
methods.value = allMethods.filter { it.episode == episode }
}
}
override fun sort(sortColumns: List<SortColumn<HuntMethodModel>>) {
methods.sortWith { a, b ->
for (sortColumn in sortColumns) {
val cmp = when (sortColumn.column.key) {
METHOD_COL_KEY -> a.name.compareTo(b.name)
TIME_COL_KEY -> a.time.value.compareTo(b.time.value)
else -> {
val type = NpcType.valueOf(sortColumn.column.key)
(a.enemyCounts[type] ?: 0) - (b.enemyCounts[type] ?: 0)
}
}
if (cmp != 0) {
return@sortWith if (sortColumn.direction == SortDirection.Asc) cmp else -cmp
}
}
0
}
}
suspend fun setMethodTime(method: HuntMethodModel, time: Duration) {
huntMethodStore.setMethodTime(method, time)
}
companion object {
const val METHOD_COL_KEY = "method"
const val TIME_COL_KEY = "time"
}
}

View File

@ -29,8 +29,7 @@ class HuntMethodModel(
val time: Val<Duration> = userTime.orElse { defaultTime }
fun setUserTime(userTime: Duration?): HuntMethodModel {
fun setUserTime(userTime: Duration?) {
_userTime.value = userTime
return this
}
}

View File

@ -0,0 +1,34 @@
package world.phantasmal.web.huntOptimizer.persistence
import world.phantasmal.web.core.models.Server
import world.phantasmal.web.core.persistence.Persister
import world.phantasmal.web.huntOptimizer.models.HuntMethodModel
import kotlin.time.hours
class HuntMethodPersister : Persister() {
suspend fun persistMethodUserTimes(huntMethods: List<HuntMethodModel>, server: Server) {
val userTimes = mutableMapOf<String, Double>()
for (method in huntMethods) {
method.userTime.value?.let { userTime ->
userTimes[method.id] = userTime.inHours
}
}
persistForServer(server, METHOD_USER_TIMES_KEY, userTimes)
}
suspend fun loadMethodUserTimes(huntMethods: List<HuntMethodModel>, server: Server) {
loadForServer<Map<String, Double>>(server, METHOD_USER_TIMES_KEY)?.let { userTimes ->
for (method in huntMethods) {
userTimes[method.id]?.let { userTime ->
method.setUserTime(userTime.hours)
}
}
}
}
companion object {
private const val METHOD_USER_TIMES_KEY = "HuntMethodStore.methodUserTimes"
}
}

View File

@ -14,15 +14,18 @@ import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.dto.QuestDto
import world.phantasmal.web.huntOptimizer.models.HuntMethodModel
import world.phantasmal.web.huntOptimizer.models.SimpleQuestModel
import world.phantasmal.web.huntOptimizer.persistence.HuntMethodPersister
import world.phantasmal.webui.stores.Store
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
import kotlin.time.Duration
import kotlin.time.minutes
class HuntMethodStore(
uiStore: UiStore,
private val uiStore: UiStore,
private val assetLoader: AssetLoader,
private val huntMethodPersister: HuntMethodPersister,
) : Store() {
private val _methods = mutableListVal<HuntMethodModel>()
@ -31,6 +34,11 @@ class HuntMethodStore(
_methods
}
suspend fun setMethodTime(method: HuntMethodModel, time: Duration) {
method.setUserTime(time)
huntMethodPersister.persistMethodUserTimes(methods.value, uiStore.server.value)
}
private fun loadMethods(server: Server) {
scope.launch(IoDispatcher) {
val quests = assetLoader.load<List<QuestDto>>("/quests.${server.slug}.json")
@ -91,6 +99,9 @@ class HuntMethodStore(
duration
)
}
.toList()
huntMethodPersister.loadMethodUserTimes(methods, server)
withContext(UiDispatcher) {
_methods.replaceAll(methods)

View File

@ -1,10 +1,13 @@
package world.phantasmal.web.huntOptimizer.widgets
import kotlinx.coroutines.launch
import org.w3c.dom.Node
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.web.huntOptimizer.controllers.MethodsForEpisodeController
import world.phantasmal.web.huntOptimizer.models.HuntMethodModel
import world.phantasmal.web.huntOptimizer.controllers.MethodsForEpisodeController.Companion.METHOD_COL_KEY
import world.phantasmal.web.huntOptimizer.controllers.MethodsForEpisodeController.Companion.TIME_COL_KEY
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Column
import world.phantasmal.webui.widgets.DurationInput
import world.phantasmal.webui.widgets.Table
import world.phantasmal.webui.widgets.Widget
@ -13,32 +16,19 @@ class MethodsForEpisodeWidget(private val ctrl: MethodsForEpisodeController) : W
div {
className = "pw-hunt-optimizer-methods-for-episode"
addChild(
Table(
values = ctrl.methods,
columns = listOf(
Column(
title = "Method",
fixed = true,
width = 250,
renderCell = { it.name },
),
Column(
title = "Time",
fixed = true,
width = 60,
renderCell = { it.time.value.toIsoString() },
),
*ctrl.enemies.map { enemy ->
Column<HuntMethodModel>(
title = enemy.simpleName,
width = 90,
renderCell = { 69 }
)
}.toTypedArray()
),
)
)
addChild(Table(
ctrl = ctrl,
renderCell = { method, column ->
when (column.key) {
METHOD_COL_KEY -> method.name
TIME_COL_KEY -> DurationInput(
value = method.time,
onChange = { scope.launch { ctrl.setMethodTime(method, it) } }
)
else -> method.enemyCounts[NpcType.valueOf(column.key)]?.toString() ?: ""
}
}
))
}
companion object {

View File

@ -72,7 +72,8 @@ class QuestEditor(
questEditorStore,
createThreeRenderer,
))
val entityImageRenderer = EntityImageRenderer(entityAssetLoader, createThreeRenderer)
val entityImageRenderer =
addDisposable(EntityImageRenderer(entityAssetLoader, createThreeRenderer))
// Main Widget
return QuestEditorWidget(

View File

@ -1,13 +1,13 @@
package world.phantasmal.web.viewer.controller
import world.phantasmal.webui.controllers.Tab
import world.phantasmal.webui.controllers.TabController
import world.phantasmal.webui.controllers.TabContainerController
sealed class ViewerTab(override val title: String) : Tab {
object Mesh : ViewerTab("Model")
object Texture : ViewerTab("Texture")
}
class ViewerController : TabController<ViewerTab>(
class ViewerController : TabContainerController<ViewerTab>(
listOf(ViewerTab.Mesh, ViewerTab.Texture)
)

View File

@ -1,30 +1,43 @@
package world.phantasmal.web.application
import kotlinx.browser.document
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.use
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.test.TestApplicationUrl
import world.phantasmal.web.test.WebTestContext
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
class ApplicationTests : WebTestSuite() {
@Test
fun initialization_and_shutdown_should_succeed_without_throwing() = test {
(listOf(null) + PwToolType.values().toList()).forEach { tool ->
Disposer().use { disposer ->
val appUrl = TestApplicationUrl(if (tool == null) "" else "/${tool.slug}")
fun initialization_and_shutdown_succeeds_with_empty_url() = test {
initialization_and_shutdown_succeeds("")
}
disposer.add(
Application(
rootElement = document.body!!,
assetLoader = components.assetLoader,
applicationUrl = appUrl,
createThreeRenderer = components.createThreeRenderer,
clock = components.clock,
)
)
}
}
@Test
fun initialization_and_shutdown_succeeds_with_hunt_optimizer_url() = test {
initialization_and_shutdown_succeeds("/" + PwToolType.HuntOptimizer.slug)
}
@Test
fun initialization_and_shutdown_succeeds_with_quest_editor_url() = test {
initialization_and_shutdown_succeeds("/" + PwToolType.QuestEditor.slug)
}
@Test
fun initialization_and_shutdown_succeeds_with_viewer_url() = test {
initialization_and_shutdown_succeeds("/" + PwToolType.Viewer.slug)
}
private fun WebTestContext.initialization_and_shutdown_succeeds(url: String) {
components.applicationUrl = TestApplicationUrl(url)
disposer.add(
Application(
rootElement = document.body!!,
assetLoader = components.assetLoader,
applicationUrl = components.applicationUrl,
createThreeRenderer = components.createThreeRenderer,
clock = components.clock,
)
)
}
}

View File

@ -20,7 +20,7 @@ class NavigationControllerTests : WebTestSuite() {
Pair("23:59:59", 41),
).forEach { (time, beats) ->
clock.currentTime = Instant.parse("2020-01-01T${time}Z")
val ctrl = NavigationController(components.uiStore, components.clock)
val ctrl = disposer.add(NavigationController(components.uiStore, components.clock))
assertEquals("@$beats", ctrl.internetTime.value)
}

View File

@ -45,7 +45,7 @@ class PathAwareTabControllerTests : WebTestSuite() {
val uiStore = disposer.add(UiStore(appUrl))
disposer.add(
PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf(
PathAwareTabContainerController(uiStore, PwToolType.HuntOptimizer, listOf(
PathAwareTab("A", "/a"),
PathAwareTab("B", "/b"),
PathAwareTab("C", "/c"),
@ -68,14 +68,14 @@ class PathAwareTabControllerTests : WebTestSuite() {
}
private fun TestContext.setup(
block: (PathAwareTabController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit,
block: (PathAwareTabContainerController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit,
) {
val applicationUrl = TestApplicationUrl("/${PwToolType.HuntOptimizer.slug}/b")
val uiStore = disposer.add(UiStore(applicationUrl))
uiStore.setCurrentTool(PwToolType.HuntOptimizer)
val ctrl = disposer.add(
PathAwareTabController(uiStore, PwToolType.HuntOptimizer, listOf(
PathAwareTabContainerController(uiStore, PwToolType.HuntOptimizer, listOf(
PathAwareTab("A", "/a"),
PathAwareTab("B", "/b"),
PathAwareTab("C", "/c"),

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.huntOptimizer
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.test.TestApplicationUrl
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
@ -9,10 +8,9 @@ import kotlin.test.Test
class HuntOptimizerTests : WebTestSuite() {
@Test
fun initialization_and_shutdown_should_succeed_without_throwing() = test {
val uiStore =
disposer.add(UiStore(TestApplicationUrl("/${PwToolType.HuntOptimizer}")))
components.applicationUrl = TestApplicationUrl("/${PwToolType.HuntOptimizer}")
val huntOptimizer = disposer.add(HuntOptimizer(components.assetLoader, uiStore))
val huntOptimizer = disposer.add(HuntOptimizer(components.assetLoader, components.uiStore))
disposer.add(huntOptimizer.initialize())
}
}

View File

@ -1,11 +1,15 @@
package world.phantasmal.web.questEditor
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.test.TestApplicationUrl
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
class QuestEditorTests : WebTestSuite() {
@Test
fun initialization_and_shutdown_should_succeed_without_throwing() = test {
components.applicationUrl = TestApplicationUrl("/${PwToolType.QuestEditor}")
val questEditor = disposer.add(
QuestEditor(components.assetLoader, components.uiStore, components.createThreeRenderer)
)

View File

@ -19,7 +19,7 @@ import kotlin.test.assertTrue
class EntityInfoControllerTests : WebTestSuite() {
@Test
fun test_unavailable_and_enabled() = asyncTest {
val ctrl = EntityInfoController(components.questEditorStore)
val ctrl = disposer.add(EntityInfoController(components.questEditorStore))
assertTrue(ctrl.unavailable.value)
assertFalse(ctrl.enabled.value)
@ -38,7 +38,7 @@ class EntityInfoControllerTests : WebTestSuite() {
@Test
fun can_read_regular_properties() = asyncTest {
val ctrl = EntityInfoController(components.questEditorStore)
val ctrl = disposer.add(EntityInfoController(components.questEditorStore))
val questNpc = QuestNpc(NpcType.Booma, Episode.I, areaId = 10, wave = 5)
questNpc.sectionId = 7
@ -63,7 +63,7 @@ class EntityInfoControllerTests : WebTestSuite() {
@Test
fun can_set_regular_properties_undo_and_redo() = asyncTest {
val ctrl = EntityInfoController(components.questEditorStore)
val ctrl = disposer.add(EntityInfoController(components.questEditorStore))
val npc = createQuestNpcModel(NpcType.Principal, Episode.I)
components.questEditorStore.setCurrentQuest(createQuestModel(npcs = listOf(npc)))

View File

@ -1,11 +1,15 @@
package world.phantasmal.web.viewer
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.test.TestApplicationUrl
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
class ViewerTests : WebTestSuite() {
@Test
fun initialization_and_shutdown_should_succeed_without_throwing() = test {
components.applicationUrl = TestApplicationUrl("/${PwToolType.Viewer}")
val viewer = disposer.add(
Viewer(components.createThreeRenderer)
)

View File

@ -8,7 +8,7 @@ interface Tab {
val title: String
}
open class TabController<T : Tab>(val tabs: List<T>) : Controller() {
open class TabContainerController<T : Tab>(val tabs: List<T>) : Controller() {
private val _activeTab: MutableVal<T?> = mutableVal(tabs.firstOrNull())
val activeTab: Val<T?> = _activeTab

View File

@ -0,0 +1,66 @@
package world.phantasmal.webui.controllers
import world.phantasmal.observable.value.list.ListVal
class Column<T>(
val key: String,
val title: String,
/**
* Whether the column stays in place while scrolling.
*/
val fixed: Boolean = false,
val width: Int,
/**
* Whether cells in this column contain an input widget.
*/
val input: Boolean = false,
val textAlign: String? = null,
val tooltip: ((T) -> String)? = null,
val sortable: Boolean = false,
)
enum class SortDirection {
Asc,
Desc,
}
interface SortColumn<T> {
val column: Column<T>
val direction: SortDirection
}
abstract class TableController<T> : Controller() {
private val sortColumns: MutableList<SortColumnImpl> = mutableListOf()
abstract val values: ListVal<T>
abstract val columns: List<Column<T>>
open fun sort(sortColumns: List<SortColumn<T>>) {
error("Not sortable.")
}
fun sortByColumn(column: Column<T>) {
require(column.sortable) { "Column ${column.key} should be sortable." }
val index = sortColumns.indexOfFirst { it.column == column }
if (index == 0) {
val sc = sortColumns[index]
sc.direction =
if (sc.direction == SortDirection.Asc) SortDirection.Desc else SortDirection.Asc
} else {
if (index != -1) {
sortColumns.removeAt(index)
}
sortColumns.add(0, SortColumnImpl(column, SortDirection.Asc))
}
sort(sortColumns)
}
private inner class SortColumnImpl(
override val column: Column<T>,
override var direction: SortDirection,
) : SortColumn<T>
}

View File

@ -8,7 +8,11 @@ import org.w3c.dom.events.Event
import org.w3c.dom.events.EventTarget
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.ListValChangeEvent
fun <E : Event> EventTarget.disposableListener(
type: String,
@ -139,3 +143,136 @@ fun Node.icon(icon: Icon): HTMLElement {
// the returned element will stay valid.
return span { span { className = iconStr } }
}
fun <T> bindChildrenTo(
parent: Element,
list: Val<List<T>>,
createChild: Node.(T, index: Int) -> Node,
): Disposable =
if (list is ListVal) {
bindChildrenTo(parent, list, createChild)
} else {
bindChildrenTo(parent, list, createChild, childrenRemoved = { /* Do nothing. */ })
}
fun <T> bindDisposableChildrenTo(
parent: Element,
list: Val<List<T>>,
createChild: Node.(T, index: Int) -> Pair<Node, Disposable>,
): Disposable =
if (list is ListVal) {
bindDisposableChildrenTo(parent, list, createChild)
} else {
val disposer = Disposer()
val listObserver = bindChildrenTo(
parent,
list,
createChild = { item, index ->
val (child, disposable) = createChild(item, index)
disposer.add(disposable)
child
},
childrenRemoved = {
disposer.disposeAll()
}
)
disposable {
disposer.dispose()
listObserver.dispose()
}
}
fun <T> bindChildrenTo(
parent: Element,
list: ListVal<T>,
createChild: Node.(T, index: Int) -> Node,
): Disposable =
bindChildrenTo(
parent,
list,
createChild,
childrenRemoved = { _, _ ->
// Do nothing.
}
)
fun <T> bindDisposableChildrenTo(
parent: Element,
list: ListVal<T>,
createChild: Node.(T, index: Int) -> Pair<Node, Disposable>,
): Disposable {
val disposer = Disposer()
val listObserver = bindChildrenTo(
parent,
list,
createChild = { value, index ->
val (child, disposable) = createChild(value, index)
disposer.add(index, disposable)
child
},
childrenRemoved = { index, count ->
disposer.removeAt(index, count)
}
)
return disposable {
disposer.dispose()
listObserver.dispose()
}
}
private fun <T> bindChildrenTo(
parent: Element,
list: Val<List<T>>,
createChild: Node.(T, index: Int) -> Node,
childrenRemoved: () -> Unit,
): Disposable =
list.observe(callNow = true) { (items) ->
parent.innerHTML = ""
childrenRemoved()
val frag = document.createDocumentFragment()
items.forEachIndexed { i, item ->
frag.appendChild(frag.createChild(item, i))
}
parent.appendChild(frag)
}
private fun <T> bindChildrenTo(
parent: Element,
list: ListVal<T>,
createChild: Node.(T, index: Int) -> Node,
childrenRemoved: (index: Int, count: Int) -> Unit,
): Disposable =
list.observeList(callNow = true) { change: ListValChangeEvent<T> ->
when (change) {
is ListValChangeEvent.Change -> {
repeat(change.removed.size) {
parent.removeChild(parent.childNodes[change.index].unsafeCast<Node>())
}
childrenRemoved(change.index, change.removed.size)
val frag = document.createDocumentFragment()
change.inserted.forEachIndexed { i, value ->
frag.appendChild(frag.createChild(value, change.index + i))
}
if (change.index >= parent.childNodes.length) {
parent.appendChild(frag)
} else {
parent.insertBefore(frag, parent.childNodes[change.index])
}
}
is ListValChangeEvent.ElementChange -> {
// TODO: Update children.
}
}
}

View File

@ -0,0 +1,69 @@
package world.phantasmal.webui.widgets
import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
import kotlin.time.Duration
import kotlin.time.minutes
class DurationInput(
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
tooltip: Val<String?> = nullVal(),
label: String? = null,
labelVal: Val<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
value: Val<Duration>,
onChange: (Duration) -> Unit = {},
) : Input<Duration>(
visible,
enabled,
tooltip,
label,
labelVal,
preferredLabelPosition,
className = "pw-duration-input",
value,
onChange,
) {
override fun interceptInputElement(input: HTMLInputElement) {
super.interceptInputElement(input)
input.type = "text"
input.classList.add("pw-duration-input-inner")
input.pattern = "(60|[0-5][0-9]):(60|[0-5][0-9])"
}
override fun getInputValue(input: HTMLInputElement): Duration {
if (':' in input.value) {
val (hoursStr, minutesStr) = input.value.split(':', limit = 2)
val hours = hoursStr.toIntOrNull()
val minutes = minutesStr.toIntOrNull()
if (hours != null && minutes != null) {
return (hours * 60 + minutes).minutes
}
}
return input.value.toIntOrNull()?.minutes ?: Duration.ZERO
}
override fun setInputValue(input: HTMLInputElement, value: Duration) {
input.value = value.toComponents { hours, minutes, _, _ ->
"${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}"
}
}
companion object {
init {
@Suppress("CssUnusedSymbol")
// language=css
style("""
.pw-duration-input-inner {
text-align: center;
}
""".trimIndent())
}
}
}

View File

@ -14,14 +14,8 @@ abstract class Input<T>(
labelVal: Val<String>?,
preferredLabelPosition: LabelPosition,
private val className: String,
private val inputClassName: String,
private val inputType: String,
private val value: Val<T>,
private val onChange: (T) -> Unit,
private val maxLength: Int?,
private val min: Int?,
private val max: Int?,
private val step: Int?,
) : LabelledControl(
visible,
enabled,
@ -37,8 +31,7 @@ abstract class Input<T>(
classList.add("pw-input", this@Input.className)
input {
classList.add("pw-input-inner", inputClassName)
type = inputType
classList.add("pw-input-inner")
observe(this@Input.enabled) { disabled = !it }
@ -54,16 +47,15 @@ abstract class Input<T>(
setInputValue(this, it)
}
this@Input.maxLength?.let { maxLength = it }
if (inputType == "number") {
this@Input.min?.let { min = it.toString() }
this@Input.max?.let { max = it.toString() }
step = this@Input.step?.toString() ?: "any"
}
interceptInputElement(this)
}
}
/**
* Called right after [createElement] and the default initialization for [element] is done.
*/
protected open fun interceptInputElement(input: HTMLInputElement) {}
protected abstract fun getInputValue(input: HTMLInputElement): T
protected abstract fun setInputValue(input: HTMLInputElement, value: T)
@ -91,7 +83,7 @@ abstract class Input<T>(
border: var(--pw-input-border);
}
.pw-input .pw-input-inner {
.pw-input-inner {
box-sizing: border-box;
width: 100%;
height: 100%;
@ -115,7 +107,7 @@ abstract class Input<T>(
border: var(--pw-input-border-disabled);
}
.pw-input.pw-disabled .pw-input-inner {
.pw-input.pw-disabled > .pw-input-inner {
color: var(--pw-input-text-color-disabled);
background-color: var(--pw-input-bg-color-disabled);
}

View File

@ -26,7 +26,7 @@ abstract class LabelledControl(
element.id = id
}
Label( visible, enabled, label, labelVal, htmlFor = id)
Label(visible, enabled, label, labelVal, htmlFor = id)
}
}

View File

@ -74,7 +74,7 @@ class Menu<T : Any>(
}
}
document.disposableListener("keydown", ::onDocumentKeyDown)
addDisposable(document.disposableListener("keydown", ::onDocumentKeyDown))
}
override fun internalDispose() {

View File

@ -1,5 +1,6 @@
package world.phantasmal.webui.widgets
import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val
abstract class NumberInput<T : Number>(
@ -11,9 +12,9 @@ abstract class NumberInput<T : Number>(
preferredLabelPosition: LabelPosition,
value: Val<T>,
onChange: (T) -> Unit,
min: Int?,
max: Int?,
step: Int?,
private val min: Int?,
private val max: Int?,
private val step: Int?,
) : Input<T>(
visible,
enabled,
@ -22,15 +23,19 @@ abstract class NumberInput<T : Number>(
labelVal,
preferredLabelPosition,
className = "pw-number-input",
inputClassName = "pw-number-input-inner",
inputType = "number",
value,
onChange,
maxLength = null,
min,
max,
step,
) {
override fun interceptInputElement(input: HTMLInputElement) {
super.interceptInputElement(input)
input.type = "number"
input.classList.add("pw-number-input-inner")
min?.let { input.min = it.toString() }
max?.let { input.max = it.toString() }
input.step = step?.toString() ?: "any"
}
companion object {
init {
@Suppress("CssUnusedSymbol")
@ -40,7 +45,7 @@ abstract class NumberInput<T : Number>(
width: 54px;
}
.pw-number-input .pw-number-input-inner {
.pw-number-input-inner {
padding-right: 1px;
}
""".trimIndent())

View File

@ -5,14 +5,14 @@ import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.eq
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.controllers.Tab
import world.phantasmal.webui.controllers.TabController
import world.phantasmal.webui.controllers.TabContainerController
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.span
class TabContainer<T : Tab>(
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
private val ctrl: TabController<T>,
private val ctrl: TabContainerController<T>,
private val createWidget: (T) -> Widget,
) : Widget(visible, enabled) {
override fun Node.createElement() =

View File

@ -1,23 +1,21 @@
package world.phantasmal.webui.widgets
import org.w3c.dom.Node
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.trueVal
import world.phantasmal.webui.controllers.Column
import world.phantasmal.webui.controllers.TableController
import world.phantasmal.webui.dom.*
class Column<T>(
val title: String,
val fixed: Boolean = false,
val width: Int,
val renderCell: (T) -> Any,
)
class Table<T>(
visible: Val<Boolean> = trueVal(),
enabled: Val<Boolean> = trueVal(),
private val values: ListVal<T>,
private val columns: List<Column<T>>,
private val ctrl: TableController<T>,
/**
* Can return a [Widget].
*/
private val renderCell: (T, Column<T>) -> Any,
) : Widget(visible, enabled) {
override fun Node.createElement() =
table {
@ -25,10 +23,14 @@ class Table<T>(
thead {
tr {
className = "pw-table-row pw-table-header-row"
var runningWidth = 0
for ((index, column) in columns.withIndex()) {
for (column in ctrl.columns) {
th {
className = "pw-table-cell"
span { textContent = column.title }
if (column.fixed) {
@ -38,29 +40,55 @@ class Table<T>(
}
style.width = "${column.width}px"
if (column.sortable) {
onmousedown = { ctrl.sortByColumn(column) }
}
}
}
}
}
tbody {
bindChildrenTo(values) { value, index ->
tr {
bindDisposableChildrenTo(ctrl.values) { value, _ ->
val rowDisposer = Disposer()
val row = tr {
className = "pw-table-row"
var runningWidth = 0
for ((index, column) in columns.withIndex()) {
for (column in ctrl.columns) {
(if (column.fixed) ::th else ::td) {
append(column.renderCell(value))
className = "pw-table-cell pw-table-body-cell"
val child = renderCell(value, column)
if (child is Widget) {
rowDisposer.add(child)
append(child.element)
} else {
append(child)
}
if (column.input) {
classList.add("pw-table-cell-input")
}
if (column.fixed) {
classList.add("pw-fixed")
classList.add("pw-table-cell-fixed")
style.left = "${runningWidth}px"
runningWidth += column.width
}
style.width = "${column.width}px"
column.textAlign?.let { style.textAlign = it }
column.tooltip?.let { title = it(value) }
}
}
}
Pair(row, rowDisposer)
}
}
}
@ -79,32 +107,36 @@ class Table<T>(
border-collapse: collapse;
}
.pw-table tr {
display: flex;
align-items: stretch;
}
.pw-table thead {
.pw-table > thead {
position: sticky;
display: inline-block;
top: 0;
z-index: 2;
}
.pw-table thead tr {
.pw-table > tbody {
user-select: text;
cursor: text;
}
.pw-table-row {
display: flex;
align-items: stretch;
}
.pw-table-header-row {
position: sticky;
top: 0;
}
.pw-table thead th {
.pw-table-header-row > th {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.pw-table th,
.pw-table td {
.pw-table-cell {
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
@ -114,51 +146,45 @@ class Table<T>(
background-color: var(--pw-bg-color);
}
.pw-table tbody {
user-select: text;
cursor: text;
}
.pw-table tbody th,
.pw-table tbody td {
.pw-table-body-cell {
white-space: nowrap;
}
.pw-table tbody th,
.pw-table tfoot th {
.pw-table-body-cell,
.pw-table-footer-cell {
text-align: left;
}
.pw-table th.pw-fixed {
.pw-table-cell-fixed {
position: sticky;
text-align: left;
}
.pw-table th.input {
.pw-table-cell-input {
padding: 0;
overflow: visible;
}
.pw-table th.input .pw-duration-input {
.pw-table-cell-input > .pw-input {
z-index: 0;
height: 100%;
width: 100%;
border: none;
}
.pw-table th.input .pw-duration-input:hover,
.pw-table th.input .pw-duration-input:focus-within {
.pw-table-cell-input > .pw-input:hover,
.pw-table-cell-input > .pw-input:focus-within {
margin: -1px;
height: calc(100% + 2px);
width: calc(100% + 2px);
}
.pw-table th.input .pw-duration-input:hover {
.pw-table-cell-input > .pw-input:hover {
z-index: 4;
border: var(--pw-input-border-hover);
}
.pw-table th.input .pw-duration-input:focus-within {
.pw-table-cell-input > .pw-input:focus-within {
z-index: 6;
border: var(--pw-input-border-focus);
}

View File

@ -15,7 +15,7 @@ class TextInput(
preferredLabelPosition: LabelPosition = LabelPosition.Before,
value: Val<String> = emptyStringVal(),
onChange: (String) -> Unit = {},
maxLength: Int? = null,
private val maxLength: Int? = null,
) : Input<String>(
visible,
enabled,
@ -24,15 +24,16 @@ class TextInput(
labelVal,
preferredLabelPosition,
className = "pw-text-input",
inputClassName = "pw-number-text-inner",
inputType = "text",
value,
onChange,
maxLength,
min = null,
max = null,
step = null
) {
override fun interceptInputElement(input: HTMLInputElement) {
super.interceptInputElement(input)
input.type = "text"
maxLength?.let { input.maxLength = it }
}
override fun getInputValue(input: HTMLInputElement): String = input.value
override fun setInputValue(input: HTMLInputElement, value: String) {

View File

@ -3,19 +3,17 @@ package world.phantasmal.webui.widgets
import kotlinx.browser.document
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import org.w3c.dom.*
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLStyleElement
import org.w3c.dom.Node
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.observable.Observable
import world.phantasmal.observable.value.*
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.ListValChangeEvent
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.HTMLElementSizeVal
import world.phantasmal.webui.dom.Size
import world.phantasmal.webui.dom.disposablePointerDrag
import world.phantasmal.webui.dom.documentFragment
import world.phantasmal.webui.dom.*
abstract class Widget(
/**
@ -121,8 +119,11 @@ abstract class Widget(
/**
* Appends a widget's element to the receiving node.
*/
protected fun <T : Widget> Node.addWidget(widget: T): T {
addDisposable(widget)
protected fun <T : Widget> Node.addWidget(widget: T, addToDisposer: Boolean = true): T {
if (addToDisposer) {
addDisposable(widget)
}
appendChild(widget.element)
return widget
}
@ -145,127 +146,30 @@ abstract class Widget(
list: Val<List<T>>,
createChild: Node.(T, index: Int) -> Node,
) {
if (list is ListVal) {
bindChildrenTo(list, createChild)
} else {
observe(list) { items ->
innerHTML = ""
val frag = document.createDocumentFragment()
items.forEachIndexed { i, item ->
frag.createChild(item, i)
}
appendChild(frag)
}
}
addDisposable(bindChildrenTo(this, list, createChild))
}
protected fun <T> Element.bindChildrenTo(
list: ListVal<T>,
createChild: Node.(T, index: Int) -> Node,
protected fun <T> Element.bindDisposableChildrenTo(
list: Val<List<T>>,
createChild: Node.(T, index: Int) -> Pair<Node, Disposable>,
) {
fun spliceChildren(index: Int, removedCount: Int, inserted: List<T>) {
for (i in 1..removedCount) {
removeChild(childNodes[index].unsafeCast<Node>())
}
val frag = document.createDocumentFragment()
inserted.forEachIndexed { i, value ->
frag.createChild(value, index + i)
}
if (index >= childNodes.length) {
appendChild(frag)
} else {
insertBefore(frag, childNodes[index])
}
}
addDisposable(
list.observeList { change: ListValChangeEvent<T> ->
when (change) {
is ListValChangeEvent.Change -> {
spliceChildren(change.index, change.removed.size, change.inserted)
}
is ListValChangeEvent.ElementChange -> {
// TODO: Update children.
}
}
}
)
spliceChildren(0, 0, list.value)
addDisposable(bindDisposableChildrenTo(this, list, createChild))
}
/**
* Creates a widget for every element in [list] and adds it as a child.
*/
protected fun <T> Element.bindChildWidgetsTo(
list: Val<List<T>>,
createChild: (T, index: Int) -> Widget,
) {
val disposer = addDisposable(Disposer())
if (list is ListVal) {
bindChildWidgetsTo(list, createChild)
} else {
observe(list) { items ->
innerHTML = ""
disposer.disposeAll()
val frag = document.createDocumentFragment()
items.forEachIndexed { i, item ->
val child = disposer.add(createChild(item, i))
frag.addChild(child, addToDisposer = false)
}
appendChild(frag)
}
}
}
protected fun <T> Element.bindChildWidgetsTo(
list: ListVal<T>,
createChild: (T, index: Int) -> Widget,
) {
val disposer = addDisposable(Disposer())
fun spliceChildren(index: Int, removedCount: Int, inserted: List<T>) {
for (i in 1..removedCount) {
removeChild(childNodes[index].unsafeCast<Node>())
}
disposer.removeAt(index, removedCount)
val frag = document.createDocumentFragment()
inserted.forEachIndexed { i, value ->
val child = addDisposable(createChild(value, index + i))
frag.addChild(child, addToDisposer = false)
}
if (index >= childNodes.length) {
appendChild(frag)
} else {
insertBefore(frag, childNodes[index])
}
val create: Node.(T, Int) -> Pair<Node, Disposable> = { value: T, index: Int ->
val widget = createChild(value, index)
addChild(widget, addToDisposer = false)
Pair<Node, Disposable>(widget.element, widget)
}
addDisposable(
list.observeList { change: ListValChangeEvent<T> ->
when (change) {
is ListValChangeEvent.Change -> {
spliceChildren(change.index, change.removed.size, change.inserted)
}
is ListValChangeEvent.ElementChange -> {
// TODO: Update children.
}
}
}
)
spliceChildren(0, 0, list.value)
addDisposable(bindDisposableChildrenTo(this, list, create))
}
fun Element.onDrag(