Inceptum/launcher-gtk/src/main/kotlin/io/gitlab/jfronny/inceptum/gtk/window/settings/instance/ModsTab.kt

280 lines
12 KiB
Kotlin

package io.gitlab.jfronny.inceptum.gtk.window.settings.instance
import io.gitlab.jfronny.commons.concurrent.AsyncRequest
import io.gitlab.jfronny.commons.concurrent.VoidFuture
import io.gitlab.jfronny.inceptum.gtk.control.ILabel
import io.gitlab.jfronny.inceptum.gtk.control.KDropDown
import io.gitlab.jfronny.inceptum.gtk.control.KSignalListItemFactory
import io.gitlab.jfronny.inceptum.gtk.control.LoadingRevealer
import io.gitlab.jfronny.inceptum.gtk.control.settings.SettingsTab
import io.gitlab.jfronny.inceptum.gtk.util.I18n
import io.gitlab.jfronny.inceptum.gtk.util.clear
import io.gitlab.jfronny.inceptum.gtk.util.fixSubtitle
import io.gitlab.jfronny.inceptum.gtk.util.replaceAll
import io.gitlab.jfronny.inceptum.launcher.LauncherEnv
import io.gitlab.jfronny.inceptum.launcher.api.CurseforgeApi
import io.gitlab.jfronny.inceptum.launcher.api.ModrinthApi
import io.gitlab.jfronny.inceptum.launcher.model.modrinth.ModrinthProjectType
import io.gitlab.jfronny.inceptum.launcher.system.instance.Instance
import io.gitlab.jfronny.inceptum.launcher.system.instance.Mod
import io.gitlab.jfronny.inceptum.launcher.system.instance.ModManager
import io.gitlab.jfronny.inceptum.launcher.system.instance.ModPath
import io.gitlab.jfronny.inceptum.launcher.system.mds.ModsDirScanner
import io.gitlab.jfronny.inceptum.launcher.system.source.CurseforgeModSource
import io.gitlab.jfronny.inceptum.launcher.system.source.ModSource
import io.gitlab.jfronny.inceptum.launcher.system.source.ModrinthModSource
import org.gnome.adw.ActionRow
import org.gnome.adw.Leaflet
import org.gnome.adw.LeafletTransitionType
import org.gnome.glib.GLib
import org.gnome.gtk.*
import org.jetbrains.annotations.PropertyKey
import java.util.concurrent.ForkJoinPool
import kotlin.jvm.optionals.getOrNull
class ModsTab(window: InstanceSettingsWindow) : SettingsTab<Leaflet, InstanceSettingsWindow>(window, Leaflet()) {
private val instance: Instance
private val mds: ModsDirScanner
private val listModel: StringList
private val loadingRevealer = LoadingRevealer()
private val descriptionLabel: ILabel
private var page: Page = Page.LOCAL
enum class Page {
LOCAL, MODRINTH, CURSEFORGE
}
init {
instance = window.instance
mds = instance.mds
content.apply {
//TODO consider filter panel via Flap
homogeneous = false
canNavigateBack = true
transitionType = LeafletTransitionType.OVER
append(Box(Orientation.VERTICAL, 6).apply {
content.visibleChild = this
append(KDropDown("instance.settings.mods.local", "instance.settings.mods.modrinth", "instance.settings.mods.curseforge").apply {
onChange { newFilter ->
when (newFilter) {
0 -> switchTo(Page.LOCAL)
1 -> switchTo(Page.MODRINTH)
2 -> switchTo(Page.CURSEFORGE)
}
}
})
append(SearchBar().apply {
hexpand = false
showCloseButton = false
searchMode = false
val entry = SearchEntry().apply {
onSearchChanged { updateSearch(text) }
}
child = entry
keyCaptureWidget = entry
})
listModel = StringList(arrayOf())
append(Overlay().apply {
child = ModsListView(listModel)
addOverlay(loadingRevealer)
})
})
append(Separator(Orientation.VERTICAL)).navigatable = false
append(Box(Orientation.VERTICAL, 6).apply {
descriptionLabel = ILabel("instance.settings.mods.select")
append(descriptionLabel)
//TODO content
})
addTickCallback { _, _ ->
val toShow = mutableListOf<String>()
if (page == Page.LOCAL) {
loadingRevealer.setRunning(!mds.isComplete)
val mods = window.instance.mods
loadingRevealer.setProgress((mods.filter { mds.hasScanned(it) }.size.toDouble() / mods.size))
for (mod in mods) {
if (mod.name.contains(currentSearchString)) {
//TODO improve this search
this@ModsTab.mods[mod.name] = ModState.Installed(mod)
toShow.add(mod.name)
}
}
listModel.replaceAll(mods.map { it.name }.toTypedArray())
} else {
loadingRevealer.setRunning(searchResult == null || !mds.isComplete)
if (searchResult != null) {
for (mod in searchResult.orEmpty()) {
this@ModsTab.mods[mod.name] = mod
toShow.add(mod.name)
}
}
}
listModel.replaceAll(toShow.toTypedArray())
GLib.SOURCE_CONTINUE
}
}
}
private var currentSearchString: String = ""
private var searchResult: List<ModState>? = null
//TODO search pagination
private val search = AsyncRequest({
searchResult = null
loadingRevealer.setRunning(true)
loadingRevealer.pulse()
VoidFuture(ForkJoinPool.commonPool().submit {
val sources: List<ModSource> = when (page) {
Page.CURSEFORGE -> {
val cf = CurseforgeApi.search(instance.gameVersion, currentSearchString, 0, "Popularity") //TODO allow user to choose sort mode
cf.mapNotNull {
if (isCancelled) return@submit
val file = it.latestFilesIndexes.firstOrNull { it.gameVersion == instance.gameVersion } //TODO support compatible minor versions
if (file == null) null
else CurseforgeModSource(it.id, file.fileId)
}
}
Page.MODRINTH -> {
val mr = ModrinthApi.search(currentSearchString, 0, instance.gameVersion, ModrinthProjectType.mod).hits
mr.mapNotNull {
if (isCancelled) return@submit
val file = ModrinthApi.getLatestVersions(it.project_id, instance.gameVersion).best
if (file == null) null
else ModrinthModSource(file.id)
}
}
Page.LOCAL -> listOf()
}
searchResult = sources.map { ModState(it) }
})
}, {
loadingRevealer.setRunning(false)
})
fun switchTo(page: Page): Unit {
listModel.clear()
mods.clear()
this.page = page
updateSearch(currentSearchString)
}
fun updateSearch(search: String): Unit {
descriptionLabel.text = "Searching is currently unsupported"
currentSearchString = search
this.search.request()
}
fun selectMod(mod: ModState): Unit {
//TODO detailed menu for version selection, ...
descriptionLabel.setMarkup(mod.description)
}
inner class ModsListView(model: StringList): ListView(ModsListSelectionModel(model), ModsListItemFactory()) {
init {
vscrollPolicy = ScrollablePolicy.NATURAL
addCssClass("navigation-sidebar")
}
}
class ModsListSelectionModel(model: StringList): SingleSelection(model) {
init {
autoselect = false
// onSelectionChanged { position, nItems ->
// val v = (selectedItem as? StringObject)?.string
// if (v != null)
// }
}
}
private val mods = LinkedHashMap<String, ModState>()
//TODO https://gitlab.gnome.org/World/gfeeds/-/blob/master/data/ui/sidebar_listbox_row.blp
inner class ModsListItemFactory: KSignalListItemFactory<Decomposed, ActionRow>() {
override fun setup(): ActionRow {
val row = ActionRow()
row.activatable = true
val quickAction = Button.newFromIconName("folder-download-symbolic")
quickAction.addCssClass("flat")
quickAction.tooltipText = I18n["instance.settings.mods.download"]
row.fixSubtitle()
return row
}
override fun BindContext.bind(widget: ActionRow, data: Decomposed) {
widget.title = data.mod!!.name
widget.subtitle = data.mod.summary
registerForUnbind(widget.onActivated { selectMod(data.mod) })
fun setupQuickAction(
iconName: String,
description: @PropertyKey(resourceBundle = I18n.BUNDLE) String,
handler: Button.Clicked
) {
data.quickAction.iconName = iconName
data.quickAction.tooltipText = I18n[description]
registerForUnbind(data.quickAction.onClicked(handler))
}
when (data.mod) {
is ModState.Installed -> if (data.mod.outdated) setupQuickAction("software-update-available-symbolic", "instance.settings.mods.update") {
data.mod.updates[0]()
}
else setupQuickAction("edit-delete-symbolic", "instance.settings.mods.delete") {
data.mod.remove()
}
is ModState.Available -> setupQuickAction("folder-download-symbolic", "instance.settings.mods.download") {
data.mod.install()
}
else -> setupQuickAction("dialog-question-symbolic", "instance.settings.mods.unknown") {
LauncherEnv.showError(I18n["instance.settings.mods.unknown.description"], I18n["instance.settings.mods.unknown"])
}
}
}
override fun UnbindContext.unbind(widget: ActionRow, data: Decomposed) {
// Not needed currently
}
override fun ActionContext.castWidget(widget: Widget): ActionRow = widget as ActionRow
override fun ActionContext.lookup(id: String, widget: ActionRow): Decomposed {
val mod = mods[id]
val suffixes = widget.firstChild!!.lastChild as Box
val quickAction = suffixes.firstChild as Button
return Decomposed(mod, quickAction)
}
}
data class Decomposed(val mod: ModState?, val quickAction: Button)
interface ModState {
val name: String
val summary: String
val description: String
class Installed(private val mod: Mod) : ModState {
private val sources = mod.metadata.sources
val updates: List<() -> Unit> = sources.mapNotNull { it.value.getOrNull() }.map { { mod.update(it) } }
val outdated get() = updates.isEmpty()
fun remove() = mod.delete()
override val name: String = mod.name
override val summary: String by lazy { mod.metadata.sources.bestSummary }
override val description: String by lazy { sources.bestDescription ?: mod.description.joinToString(separator = "\n") }
}
class Available(private val mod: ModSource, private val instance: Instance) : ModState {
fun install() {
//TODO thread and possibly show progress
ModManager.download(mod, instance.modsDir.resolve(mod.shortName + ModPath.EXT_IMOD), instance.mds)
.write()
}
override val name: String = mod.name
override val summary: String = mod.summary
override val description: String = mod.description
}
}
fun ModState(mod: ModSource): ModState {
val installed = mds.mods.filter { it.metadata.sources.keys.any { mod.projectMatches(it) } }
return if (installed.isEmpty()) ModState.Available(mod, instance)
else ModState.Installed(installed[0])
}
}