mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 14:38:32 +08:00
Hunt method tables now show correct data again and times can be edited and persisted again.
This commit is contained in:
parent
41f8e53efc
commit
c3927729ad
@ -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.
|
||||
*/
|
||||
|
@ -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(".")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
@ -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)
|
||||
|
@ -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))
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -72,7 +72,8 @@ class QuestEditor(
|
||||
questEditorStore,
|
||||
createThreeRenderer,
|
||||
))
|
||||
val entityImageRenderer = EntityImageRenderer(entityAssetLoader, createThreeRenderer)
|
||||
val entityImageRenderer =
|
||||
addDisposable(EntityImageRenderer(entityAssetLoader, createThreeRenderer))
|
||||
|
||||
// Main Widget
|
||||
return QuestEditorWidget(
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"),
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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)))
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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
|
@ -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>
|
||||
}
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ abstract class LabelledControl(
|
||||
element.id = id
|
||||
}
|
||||
|
||||
Label( visible, enabled, label, labelVal, htmlFor = id)
|
||||
Label(visible, enabled, label, labelVal, htmlFor = id)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -74,7 +74,7 @@ class Menu<T : Any>(
|
||||
}
|
||||
}
|
||||
|
||||
document.disposableListener("keydown", ::onDocumentKeyDown)
|
||||
addDisposable(document.disposableListener("keydown", ::onDocumentKeyDown))
|
||||
}
|
||||
|
||||
override fun internalDispose() {
|
||||
|
@ -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())
|
||||
|
@ -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() =
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user