mirror of
https://github.com/yairm210/Unciv.git
synced 2025-01-05 21:11:35 +07:00
Mod manager smallish overhaul (#9878)
* Mod Manager - move classes, visibility * Mod Manager - separate metadata from UI buttons * Mod Manager - split off info/actions pane and make it scrollable * Mod Manager - fix bottom button mouseover * Mod Manager - getRepoSize lint and doc * Mod Manager - banner for builtin rulesets and hide visual checkbox in obvious-BS cases * Mod Manager - MB rounded to next instead of down * Mod Manager - One missed lint * Post-merge sort imports * Avatars as fallback for preview
This commit is contained in:
parent
756431ee74
commit
a4a43dadc1
@ -0,0 +1,81 @@
|
||||
package com.unciv.ui.screens.modmanager
|
||||
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.unciv.models.metadata.ModCategories
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
|
||||
/** A mod button on the Mod Manager Screen...
|
||||
*
|
||||
* Used both in the "installed" and the "online/downloadable" columns.
|
||||
* The "installed" version shows indicators for "Selected as permanent visual mod" and "update available",
|
||||
* as read from the [modInfo] fields, but requires a [updateIndicators] call when those change.
|
||||
*/
|
||||
internal class ModDecoratedButton(private val modInfo: ModUIData) : Table() {
|
||||
private val stateImages: ModStateImages?
|
||||
private val textButton: TextButton
|
||||
|
||||
init {
|
||||
touchable = Touchable.enabled
|
||||
|
||||
val topics = modInfo.topics()
|
||||
val categories = ArrayList<ModCategories.Category>()
|
||||
for (category in ModCategories) {
|
||||
if (category == ModCategories.default()) continue
|
||||
if (topics.contains(category.topic)) categories += category
|
||||
}
|
||||
|
||||
textButton = modInfo.buttonText().toTextButton()
|
||||
val topicString = categories.joinToString { it.label.tr() }
|
||||
if (categories.isNotEmpty()) {
|
||||
textButton.row()
|
||||
textButton.add(topicString.toLabel(fontSize = 14))
|
||||
}
|
||||
|
||||
add(textButton)
|
||||
|
||||
if (modInfo.ruleset == null) {
|
||||
stateImages = null
|
||||
} else {
|
||||
stateImages = ModStateImages()
|
||||
add(stateImages).align(Align.left)
|
||||
updateIndicators()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateIndicators() = stateImages?.update(modInfo)
|
||||
|
||||
fun setText(text: String) = textButton.setText(text)
|
||||
override fun setColor(color: Color) { textButton.color = color }
|
||||
override fun getColor(): Color = textButton.color
|
||||
|
||||
/** Helper class keeps references to decoration images of installed mods to enable dynamic visibility
|
||||
* (actually we do not use isVisible but refill thiis container selectively which allows the aggregate height to adapt and the set to center vertically)
|
||||
*/
|
||||
private class ModStateImages : Table() {
|
||||
/** image indicating _enabled as permanent visual mod_ */
|
||||
private val visualImage: Image = ImageGetter.getImage("UnitPromotionIcons/Scouting")
|
||||
/** image indicating _online mod has been updated_ */
|
||||
private val hasUpdateImage: Image = ImageGetter.getImage("OtherIcons/Mods")
|
||||
|
||||
init {
|
||||
defaults().size(20f).align(Align.topLeft)
|
||||
}
|
||||
|
||||
fun update(modInfo: ModUIData) {
|
||||
clear()
|
||||
if (modInfo.isVisual) add(visualImage).row()
|
||||
if (modInfo.hasUpdate) add(hasUpdateImage).row()
|
||||
pack()
|
||||
}
|
||||
|
||||
override fun getMinWidth() = 20f
|
||||
}
|
||||
}
|
169
core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt
Normal file
169
core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt
Normal file
@ -0,0 +1,169 @@
|
||||
package com.unciv.ui.screens.modmanager
|
||||
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.graphics.Texture
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.unciv.models.metadata.BaseRuleset
|
||||
import com.unciv.models.ruleset.Ruleset
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.components.extensions.UncivDateFormat.formatDate
|
||||
import com.unciv.ui.components.extensions.UncivDateFormat.parseDate
|
||||
import com.unciv.ui.components.extensions.toCheckBox
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.components.input.onClick
|
||||
import com.unciv.ui.screens.pickerscreens.Github
|
||||
import com.unciv.utils.Concurrency
|
||||
import kotlin.math.max
|
||||
|
||||
internal class ModInfoAndActionPane : Table() {
|
||||
private val repoUrlToPreviewImage = HashMap<String, Texture?>()
|
||||
private val imageHolder = Table()
|
||||
private val sizeLabel = "".toLabel()
|
||||
private var isBuiltin = false
|
||||
private var disableVisualCheckBox = false
|
||||
|
||||
init {
|
||||
defaults().pad(10f)
|
||||
}
|
||||
|
||||
/** Recreate the information part of the right-hand column
|
||||
* @param repo: the repository instance as received from the GitHub api
|
||||
*/
|
||||
fun update(repo: Github.Repo) {
|
||||
isBuiltin = false
|
||||
disableVisualCheckBox = true
|
||||
update(
|
||||
repo.name, repo.html_url, repo.default_branch,
|
||||
repo.pushed_at, repo.owner.login, repo.size,
|
||||
repo.owner.avatar_url
|
||||
)
|
||||
}
|
||||
|
||||
/** Recreate the information part of the right-hand column
|
||||
* @param mod: The mod RuleSet (from RulesetCache)
|
||||
*/
|
||||
fun update(mod: Ruleset) {
|
||||
val modName = mod.name
|
||||
val modOptions = mod.modOptions // The ModOptions as enriched by us with GitHub metadata when originally downloaded
|
||||
isBuiltin = modOptions.modUrl.isEmpty() && BaseRuleset.values().any { it.fullName == modName }
|
||||
disableVisualCheckBox = mod.folderLocation?.list("atlas")?.isEmpty() ?: true // Also catches isBuiltin
|
||||
update(
|
||||
modName, modOptions.modUrl, modOptions.defaultBranch,
|
||||
modOptions.lastUpdated, modOptions.author, modOptions.modSize
|
||||
)
|
||||
}
|
||||
|
||||
private fun update(
|
||||
modName: String,
|
||||
repoUrl: String,
|
||||
defaultBranch: String,
|
||||
updatedAt: String,
|
||||
author: String,
|
||||
modSize: Int,
|
||||
avatarUrl: String? = null
|
||||
) {
|
||||
// Display metadata
|
||||
clear()
|
||||
|
||||
imageHolder.clear()
|
||||
when {
|
||||
isBuiltin -> addUncivLogo()
|
||||
repoUrl.isEmpty() -> addLocalPreviewImage(modName)
|
||||
else -> addPreviewImage(repoUrl, defaultBranch, avatarUrl)
|
||||
}
|
||||
add(imageHolder).row()
|
||||
|
||||
if (author.isNotEmpty())
|
||||
add("Author: [$author]".toLabel()).row()
|
||||
|
||||
updateSize(modSize)
|
||||
add(sizeLabel).padBottom(15f).row()
|
||||
|
||||
// offer link to open the repo itself in a browser
|
||||
if (repoUrl.isNotEmpty()) {
|
||||
add("Open Github page".toTextButton().onClick {
|
||||
Gdx.net.openURI(repoUrl)
|
||||
}).row()
|
||||
}
|
||||
|
||||
// display "updated" date
|
||||
if (updatedAt.isNotEmpty()) {
|
||||
val date = updatedAt.parseDate()
|
||||
val updateString = "{Updated}: " + date.formatDate()
|
||||
add(updateString.toLabel()).row()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSize(size: Int) {
|
||||
val text = when {
|
||||
size <= 0 -> ""
|
||||
size < 2048 -> "Size: [$size] kB"
|
||||
else -> "Size: [${(size + 512) / 1024}] MB"
|
||||
}
|
||||
sizeLabel.setText(text.tr())
|
||||
}
|
||||
|
||||
fun addVisualCheckBox(startsOutChecked: Boolean = false, changeAction: ((Boolean)->Unit)? = null) {
|
||||
if (disableVisualCheckBox) return
|
||||
add("Permanent audiovisual mod".toCheckBox(startsOutChecked, changeAction)).row()
|
||||
}
|
||||
|
||||
fun addUpdateModButton(modInfo: ModUIData, doDownload: () -> Unit) {
|
||||
if (!modInfo.hasUpdate) return
|
||||
val updateModTextbutton = "Update [${modInfo.name}]".toTextButton()
|
||||
updateModTextbutton.onClick {
|
||||
updateModTextbutton.setText("Downloading...".tr())
|
||||
doDownload()
|
||||
}
|
||||
add(updateModTextbutton).row()
|
||||
}
|
||||
|
||||
private fun addPreviewImage(repoUrl: String, defaultBranch: String, avatarUrl: String?) {
|
||||
if (!repoUrl.startsWith("http")) return // invalid url
|
||||
|
||||
if (repoUrlToPreviewImage.containsKey(repoUrl)) {
|
||||
val texture = repoUrlToPreviewImage[repoUrl]
|
||||
if (texture != null) setTextureAsPreview(texture)
|
||||
return
|
||||
}
|
||||
|
||||
Concurrency.run {
|
||||
val imagePixmap = Github.tryGetPreviewImage(repoUrl, defaultBranch, avatarUrl)
|
||||
|
||||
if (imagePixmap == null) {
|
||||
repoUrlToPreviewImage[repoUrl] = null
|
||||
return@run
|
||||
}
|
||||
Concurrency.runOnGLThread {
|
||||
val texture = Texture(imagePixmap)
|
||||
imagePixmap.dispose()
|
||||
repoUrlToPreviewImage[repoUrl] = texture
|
||||
setTextureAsPreview(texture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addLocalPreviewImage(modName: String) {
|
||||
// No concurrency, order of magnitude 20ms
|
||||
val modFolder = Gdx.files.local("mods/$modName")
|
||||
val previewFile = modFolder.child("preview.jpg").takeIf { it.exists() }
|
||||
?: modFolder.child("preview.png").takeIf { it.exists() }
|
||||
?: return
|
||||
setTextureAsPreview(Texture(previewFile))
|
||||
}
|
||||
|
||||
private fun addUncivLogo() {
|
||||
setTextureAsPreview(Texture(Gdx.files.internal("ExtraImages/banner.png")))
|
||||
}
|
||||
|
||||
private fun setTextureAsPreview(texture: Texture) {
|
||||
val cell = imageHolder.add(Image(texture))
|
||||
val largestImageSize = max(texture.width, texture.height)
|
||||
if (largestImageSize > ModManagementScreen.maxAllowedPreviewImageSize) {
|
||||
val resizeRatio = ModManagementScreen.maxAllowedPreviewImageSize / largestImageSize
|
||||
cell.size(texture.width * resizeRatio, texture.height * resizeRatio)
|
||||
}
|
||||
}
|
||||
}
|
@ -2,28 +2,22 @@ package com.unciv.ui.screens.modmanager
|
||||
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.unciv.Constants
|
||||
import com.unciv.models.metadata.ModCategories
|
||||
import com.unciv.models.ruleset.Ruleset
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox
|
||||
import com.unciv.ui.screens.basescreen.BaseScreen
|
||||
import com.unciv.ui.components.ExpanderTab
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
import com.unciv.ui.components.UncivTextField
|
||||
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
|
||||
import com.unciv.ui.components.extensions.surroundWithCircle
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
import com.unciv.ui.components.input.keyShortcuts
|
||||
import com.unciv.ui.components.input.onActivation
|
||||
import com.unciv.ui.components.input.onChange
|
||||
import com.unciv.ui.components.extensions.surroundWithCircle
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.screens.pickerscreens.Github
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.screens.basescreen.BaseScreen
|
||||
import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
@ -33,7 +27,7 @@ import kotlin.math.sign
|
||||
* It holds the variables [sortInstalled] and [sortOnline] for the [modManagementScreen] and knows
|
||||
* how to sort collections of [ModUIData] by providing comparators.
|
||||
*/
|
||||
class ModManagementOptions(private val modManagementScreen: ModManagementScreen) {
|
||||
internal class ModManagementOptions(private val modManagementScreen: ModManagementScreen) {
|
||||
companion object {
|
||||
val sortByName = Comparator { mod1, mod2: ModUIData -> mod1.name.compareTo(mod2.name, true) }
|
||||
val sortByNameDesc = Comparator { mod1, mod2: ModUIData -> mod2.name.compareTo(mod1.name, true) }
|
||||
@ -45,7 +39,7 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
|
||||
10 * (mod2.stargazers() - mod1.stargazers()) + mod1.name.compareTo(mod2.name, true).sign
|
||||
}
|
||||
val sortByStatus = Comparator { mod1, mod2: ModUIData ->
|
||||
10 * (mod2.state.sortWeight() - mod1.state.sortWeight()) + mod1.name.compareTo(mod2.name, true).sign
|
||||
10 * (mod2.stateSortWeight() - mod1.stateSortWeight()) + mod1.name.compareTo(mod2.name, true).sign
|
||||
}
|
||||
|
||||
const val installedHeaderText = "Current mods"
|
||||
@ -190,112 +184,3 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
|
||||
modManagementScreen.refreshOnlineModTable()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTextButton(nameString: String, topics: List<String>): TextButton {
|
||||
val categories = ArrayList<ModCategories.Category>()
|
||||
for (category in ModCategories) {
|
||||
if (category == ModCategories.default()) continue
|
||||
if (topics.contains(category.topic)) categories += category
|
||||
}
|
||||
|
||||
val button = nameString.toTextButton()
|
||||
val topicString = categories.joinToString { it.label.tr() }
|
||||
if (categories.isNotEmpty()) {
|
||||
button.row()
|
||||
button.add(topicString.toLabel(fontSize = 14))
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
/** Helper class holds combined mod info for ModManagementScreen, used for both installed and online lists
|
||||
*
|
||||
* Note it is guaranteed either ruleset or repo are non-null, never both.
|
||||
*/
|
||||
class ModUIData private constructor(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val ruleset: Ruleset?,
|
||||
val repo: Github.Repo?,
|
||||
var y: Float,
|
||||
var height: Float,
|
||||
var button: TextButton
|
||||
) {
|
||||
var state = ModStateImages() // visible only on the 'installed' side - todo?
|
||||
|
||||
constructor(ruleset: Ruleset): this (
|
||||
ruleset.name,
|
||||
ruleset.getSummary().let {
|
||||
"Installed".tr() + (if (it.isEmpty()) "" else ": $it")
|
||||
},
|
||||
ruleset, null, 0f, 0f, getTextButton(ruleset.name, ruleset.modOptions.topics)
|
||||
)
|
||||
|
||||
constructor(repo: Github.Repo, isUpdated: Boolean): this (
|
||||
repo.name,
|
||||
(repo.description ?: "-{No description provided}-".tr()) +
|
||||
"\n" + "[${repo.stargazers_count}]✯".tr(),
|
||||
null, repo, 0f, 0f,
|
||||
getTextButton(repo.name + (if (isUpdated) " - {Updated}" else ""), repo.topics)
|
||||
) {
|
||||
state.hasUpdate = isUpdated
|
||||
}
|
||||
|
||||
|
||||
fun lastUpdated() = ruleset?.modOptions?.lastUpdated ?: repo?.pushed_at ?: ""
|
||||
fun stargazers() = repo?.stargazers_count ?: 0
|
||||
fun author() = ruleset?.modOptions?.author ?: repo?.owner?.login ?: ""
|
||||
|
||||
fun matchesFilter(filter: ModManagementOptions.Filter): Boolean = when {
|
||||
!matchesCategory(filter) -> false
|
||||
filter.text.isEmpty() -> true
|
||||
name.contains(filter.text, true) -> true
|
||||
// description.contains(filterText, true) -> true // too many surprises as description is different in the two columns
|
||||
author().contains(filter.text, true) -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun matchesCategory(filter: ModManagementOptions.Filter): Boolean {
|
||||
if (filter.topic == ModCategories.default().topic)
|
||||
return true
|
||||
val modTopics = repo?.topics ?: ruleset?.modOptions?.topics!!
|
||||
return filter.topic in modTopics
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper class keeps references to decoration images of installed mods to enable dynamic visibility
|
||||
* (actually we do not use isVisible but refill a container selectively which allows the aggregate height to adapt and the set to center vertically)
|
||||
* @param visualImage image indicating _enabled as permanent visual mod_
|
||||
* @param hasUpdateImage image indicating _online mod has been updated_
|
||||
*/
|
||||
class ModStateImages (
|
||||
isVisual: Boolean = false,
|
||||
isUpdated: Boolean = false,
|
||||
private val visualImage: Image = ImageGetter.getImage("UnitPromotionIcons/Scouting"),
|
||||
private val hasUpdateImage: Image = ImageGetter.getImage("OtherIcons/Mods")
|
||||
) {
|
||||
/** The table containing the indicators (one per mod, narrow, arranges up to three indicators vertically) */
|
||||
val container: Table = Table().apply { defaults().size(20f).align(Align.topLeft) }
|
||||
// mad but it's really initializing with the primary constructor parameter and not calling update()
|
||||
var isVisual: Boolean = isVisual
|
||||
set(value) { if (field!=value) { field = value; update() } }
|
||||
var hasUpdate: Boolean = isUpdated
|
||||
set(value) { if (field!=value) { field = value; update() } }
|
||||
private val spacer = Table().apply { width = 20f; height = 0f }
|
||||
|
||||
fun update() {
|
||||
container.run {
|
||||
clear()
|
||||
if (isVisual) add(visualImage).row()
|
||||
if (hasUpdate) add(hasUpdateImage).row()
|
||||
if (!isVisual && !hasUpdate) add(spacer)
|
||||
pack()
|
||||
}
|
||||
}
|
||||
|
||||
fun sortWeight() = when {
|
||||
hasUpdate && isVisual -> 3
|
||||
hasUpdate -> 2
|
||||
isVisual -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,8 @@ package com.unciv.ui.screens.modmanager
|
||||
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.Texture
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Button
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Label
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
@ -16,28 +13,26 @@ import com.badlogic.gdx.utils.SerializationException
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.json.fromJsonFile
|
||||
import com.unciv.json.json
|
||||
import com.unciv.models.ruleset.ModOptions
|
||||
import com.unciv.models.ruleset.Ruleset
|
||||
import com.unciv.models.ruleset.RulesetCache
|
||||
import com.unciv.models.tilesets.TileSetCache
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.components.AutoScrollPane
|
||||
import com.unciv.ui.components.ExpanderTab
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
import com.unciv.ui.components.UncivTextField
|
||||
import com.unciv.ui.components.WrappableLabel
|
||||
import com.unciv.ui.components.extensions.UncivDateFormat.formatDate
|
||||
import com.unciv.ui.components.extensions.UncivDateFormat.parseDate
|
||||
import com.unciv.ui.components.extensions.addSeparator
|
||||
import com.unciv.ui.components.extensions.disable
|
||||
import com.unciv.ui.components.extensions.enable
|
||||
import com.unciv.ui.components.extensions.isEnabled
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.components.input.ActivationTypes
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
import com.unciv.ui.components.input.clearActivationActions
|
||||
import com.unciv.ui.components.input.keyShortcuts
|
||||
import com.unciv.ui.components.input.onActivation
|
||||
import com.unciv.ui.components.input.onClick
|
||||
import com.unciv.ui.components.extensions.toCheckBox
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.popups.ConfirmPopup
|
||||
import com.unciv.ui.popups.Popup
|
||||
@ -45,9 +40,9 @@ import com.unciv.ui.popups.ToastPopup
|
||||
import com.unciv.ui.screens.basescreen.BaseScreen
|
||||
import com.unciv.ui.screens.basescreen.RecreateOnResize
|
||||
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
|
||||
import com.unciv.ui.screens.pickerscreens.Github.repoNameToFolderName
|
||||
import com.unciv.ui.screens.modmanager.ModManagementOptions.SortType
|
||||
import com.unciv.ui.screens.pickerscreens.Github
|
||||
import com.unciv.ui.screens.pickerscreens.Github.repoNameToFolderName
|
||||
import com.unciv.ui.screens.pickerscreens.PickerScreen
|
||||
import com.unciv.utils.Concurrency
|
||||
import com.unciv.utils.Log
|
||||
@ -58,15 +53,19 @@ import java.io.IOException
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The Mod Management Screen - called only from [MainMenuScreen]
|
||||
* @param previousOnlineMods - cached online mod list, if supplied and not empty, it will be displayed as is and no online query will be run. Used for resize.
|
||||
* The Mod Management Screen - constructor for internal use by [resize]
|
||||
* @param previousInstalledMods - cached installed mod list.
|
||||
* @param previousOnlineMods - cached online mod list, if supplied and not empty, it will be displayed as is and no online query will be run.
|
||||
*/
|
||||
// All picker screens auto-wrap the top table in a ScrollPane.
|
||||
// Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
|
||||
class ModManagementScreen(
|
||||
previousInstalledMods: HashMap<String, ModUIData>? = null,
|
||||
previousOnlineMods: HashMap<String, ModUIData>? = null
|
||||
class ModManagementScreen private constructor(
|
||||
previousInstalledMods: HashMap<String, ModUIData>?,
|
||||
previousOnlineMods: HashMap<String, ModUIData>?
|
||||
): PickerScreen(disableScroll = true), RecreateOnResize {
|
||||
/** The Mod Management Screen - called only from [MainMenuScreen] */
|
||||
constructor() : this(null, null)
|
||||
|
||||
companion object {
|
||||
// Tweakable constants
|
||||
/** For preview.png */
|
||||
@ -80,15 +79,20 @@ class ModManagementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private val modTable = Table().apply { defaults().pad(10f) }
|
||||
private val scrollInstalledMods = AutoScrollPane(modTable)
|
||||
private val downloadTable = Table().apply { defaults().pad(10f) }
|
||||
private val scrollOnlineMods = AutoScrollPane(downloadTable)
|
||||
private val modActionTable = Table().apply { defaults().pad(10f) }
|
||||
// Left column (in landscape, portrait stacks them within expanders)
|
||||
private val installedModsTable = Table().apply { defaults().pad(10f) }
|
||||
private val scrollInstalledMods = AutoScrollPane(installedModsTable)
|
||||
// Center column
|
||||
private val onlineModsTable = Table().apply { defaults().pad(10f) }
|
||||
private val scrollOnlineMods = AutoScrollPane(onlineModsTable)
|
||||
// Right column
|
||||
private val modActionTable = ModInfoAndActionPane()
|
||||
private val scrollActionTable = AutoScrollPane(modActionTable)
|
||||
// Factory for the Widget floating top right
|
||||
private val optionsManager = ModManagementOptions(this)
|
||||
|
||||
private var lastSelectedButton: Button? = null
|
||||
private var lastSyncMarkedButton: Button? = null
|
||||
private var lastSelectedButton: ModDecoratedButton? = null
|
||||
private var lastSyncMarkedButton: ModDecoratedButton? = null
|
||||
private var selectedMod: Github.Repo? = null
|
||||
|
||||
private val modDescriptionLabel: WrappableLabel
|
||||
@ -98,12 +102,11 @@ class ModManagementScreen(
|
||||
private var installedExpanderTab: ExpanderTab? = null
|
||||
private var onlineExpanderTab: ExpanderTab? = null
|
||||
|
||||
|
||||
// Enable re-sorting and syncing entries in 'installed' and 'repo search' ScrollPanes
|
||||
// Keep metadata and buttons in separate pools
|
||||
private val installedModInfo = previousInstalledMods ?: HashMap(10) // HashMap<String, ModUIData> inferred
|
||||
private val onlineModInfo = previousOnlineMods ?: HashMap(90) // HashMap<String, ModUIData> inferred
|
||||
|
||||
private var onlineScrollCurrentY = -1f
|
||||
private val modButtons: HashMap<ModUIData, ModDecoratedButton> = HashMap(100)
|
||||
|
||||
// cleanup - background processing needs to be stopped on exit and memory freed
|
||||
private var runningSearchJob: Job? = null
|
||||
@ -118,7 +121,11 @@ class ModManagementScreen(
|
||||
|
||||
|
||||
init {
|
||||
//setDefaultCloseAction(screen) // this would initialize the new MainMenuScreen immediately
|
||||
pickerPane.bottomTable.background = skinStrings.getUiBackground("ModManagementScreen/BottomTable", tintColor = skinStrings.skinConfig.clearColor)
|
||||
pickerPane.topTable.background = skinStrings.getUiBackground("ModManagementScreen/TopTable", tintColor = skinStrings.skinConfig.clearColor)
|
||||
topTable.top() // So short lists won't vertically center everything including headers
|
||||
|
||||
//setDefaultCloseAction() // we're adding the tileSet check
|
||||
rightSideButton.isVisible = false
|
||||
closeButton.onActivation {
|
||||
val tileSets = ImageGetter.getAvailableTilesets()
|
||||
@ -142,18 +149,18 @@ class ModManagementScreen(
|
||||
labelWrapper.add(modDescriptionLabel).row()
|
||||
labelScroll.actor = labelWrapper
|
||||
|
||||
refreshInstalledModTable()
|
||||
|
||||
if (isNarrowerThan4to3()) initPortrait()
|
||||
else initLandscape()
|
||||
|
||||
if (installedModInfo.isEmpty())
|
||||
refreshInstalledModInfo()
|
||||
refreshInstalledModTable()
|
||||
|
||||
if (onlineModInfo.isEmpty())
|
||||
reloadOnlineMods()
|
||||
else
|
||||
refreshOnlineModTable()
|
||||
|
||||
pickerPane.bottomTable.background = skinStrings.getUiBackground("ModManagementScreen/BottomTable", tintColor = skinStrings.skinConfig.clearColor)
|
||||
pickerPane.topTable.background = skinStrings.getUiBackground("ModManagementScreen/TopTable", tintColor = skinStrings.skinConfig.clearColor)
|
||||
}
|
||||
|
||||
private fun initPortrait() {
|
||||
@ -162,19 +169,19 @@ class ModManagementScreen(
|
||||
topTable.add(optionsManager.expander).top().growX().row()
|
||||
|
||||
installedExpanderTab = ExpanderTab(optionsManager.getInstalledHeader(), expanderWidth = stage.width) {
|
||||
it.add(scrollInstalledMods).growX()
|
||||
it.add(scrollInstalledMods).growX().maxHeight(stage.height / 2)
|
||||
}
|
||||
topTable.add(installedExpanderTab).top().growX().row()
|
||||
|
||||
onlineExpanderTab = ExpanderTab(optionsManager.getOnlineHeader(), expanderWidth = stage.width) {
|
||||
it.add(scrollOnlineMods).growX()
|
||||
it.add(scrollOnlineMods).growX().maxHeight(stage.height / 2)
|
||||
}
|
||||
topTable.add(onlineExpanderTab).top().padTop(10f).growX().row()
|
||||
|
||||
topTable.add().expandY().row() // helps with top() being ignored
|
||||
topTable.add().expandY().row() // keep action / info on the bottom if there's room to spare
|
||||
|
||||
topTable.add(ExpanderTab("Mod info and options", expanderWidth = stage.width) {
|
||||
it.add(modActionTable).growX()
|
||||
it.add(scrollActionTable).growX().maxHeight(stage.height / 2)
|
||||
}).bottom().padTop(10f).growX().row()
|
||||
}
|
||||
|
||||
@ -186,26 +193,23 @@ class ModManagementScreen(
|
||||
optionsManager.installedHeaderClicked()
|
||||
}
|
||||
topTable.add(installedHeaderLabel).pad(15f).minWidth(200f).padLeft(25f)
|
||||
// 30 = 5 default pad + 20 to compensate for 'permanent visual mod' decoration icon
|
||||
onlineHeaderLabel = optionsManager.getOnlineHeader().toLabel()
|
||||
onlineHeaderLabel!!.onClick {
|
||||
optionsManager.onlineHeaderClicked()
|
||||
}
|
||||
topTable.add(onlineHeaderLabel).pad(15f)
|
||||
topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions"
|
||||
topTable.add().expandX()
|
||||
topTable.row()
|
||||
topTable.add().expandX().row()
|
||||
|
||||
// horizontal separator looking like the SplitPane handle
|
||||
topTable.addSeparator(Color.CLEAR, 5, 3f)
|
||||
|
||||
// main row containing the three 'blocks' installed, online and information
|
||||
topTable.add() // skip empty first column
|
||||
topTable.add().expandX() // skip empty first column
|
||||
topTable.add(scrollInstalledMods)
|
||||
topTable.add(scrollOnlineMods)
|
||||
topTable.add(modActionTable)
|
||||
topTable.add().row()
|
||||
topTable.add().expandY() // So short lists won't vertically center everything including headers
|
||||
topTable.add(scrollActionTable)
|
||||
topTable.add().expandX().row()
|
||||
|
||||
stage.addActor(optionsManager.expander)
|
||||
optionsManager.expanderChangeEvent = {
|
||||
@ -216,11 +220,10 @@ class ModManagementScreen(
|
||||
}
|
||||
|
||||
private fun reloadOnlineMods() {
|
||||
onlineScrollCurrentY = -1f
|
||||
downloadTable.clear()
|
||||
onlineModsTable.clear()
|
||||
onlineModInfo.clear()
|
||||
downloadTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
|
||||
downloadTable.add("...".toLabel()).row()
|
||||
onlineModsTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
|
||||
onlineModsTable.add("...".toLabel()).row()
|
||||
tryDownloadPage(1)
|
||||
}
|
||||
|
||||
@ -253,10 +256,10 @@ class ModManagementScreen(
|
||||
|
||||
private fun addModInfoFromRepoSearch(repoSearch: Github.RepoSearch, pageNum: Int){
|
||||
// clear and remove last cell if it is the "..." indicator
|
||||
val lastCell = downloadTable.cells.lastOrNull()
|
||||
val lastCell = onlineModsTable.cells.lastOrNull()
|
||||
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
|
||||
lastCell.setActor<Actor>(null)
|
||||
downloadTable.cells.removeValue(lastCell, true)
|
||||
onlineModsTable.cells.removeValue(lastCell, true)
|
||||
}
|
||||
|
||||
for (repo in repoSearch.items) {
|
||||
@ -279,7 +282,9 @@ class ModManagementScreen(
|
||||
if (installedMod != null) {
|
||||
|
||||
if (isUpdatedVersionOfInstalledMod) {
|
||||
installedModInfo[repo.name]!!.state.hasUpdate = true
|
||||
val modInfo = installedModInfo[repo.name]!!
|
||||
modInfo.hasUpdate = true
|
||||
modButtons[modInfo]?.updateIndicators()
|
||||
}
|
||||
|
||||
if (installedMod.modOptions.author.isEmpty()) {
|
||||
@ -300,14 +305,7 @@ class ModManagementScreen(
|
||||
|
||||
val mod = ModUIData(repo, isUpdatedVersionOfInstalledMod)
|
||||
onlineModInfo[repo.name] = mod
|
||||
mod.button.onClick { onlineButtonAction(repo, mod.button) }
|
||||
|
||||
val cell = downloadTable.add(mod.button)
|
||||
downloadTable.row()
|
||||
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
|
||||
mod.y = onlineScrollCurrentY
|
||||
mod.height = cell.prefHeight
|
||||
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||
onlineModsTable.add(getCachedModButton(mod)).row()
|
||||
}
|
||||
|
||||
// Now the tasks after the 'page' of search results has been fully processed
|
||||
@ -318,31 +316,31 @@ class ModManagementScreen(
|
||||
val retryLabel = "Online query result is incomplete".toLabel(Color.RED)
|
||||
retryLabel.touchable = Touchable.enabled
|
||||
retryLabel.onClick { reloadOnlineMods() }
|
||||
downloadTable.add(retryLabel)
|
||||
onlineModsTable.add(retryLabel)
|
||||
}
|
||||
} else {
|
||||
// the page was full so there may be more pages.
|
||||
// indicate that search will be continued
|
||||
downloadTable.add("...".toLabel()).row()
|
||||
onlineModsTable.add("...".toLabel()).row()
|
||||
}
|
||||
|
||||
downloadTable.pack()
|
||||
onlineModsTable.pack()
|
||||
// Shouldn't actor.parent.actor = actor be a no-op? No, it has side effects we need.
|
||||
// See [commit for #3317](https://github.com/yairm210/Unciv/commit/315a55f972b8defe22e76d4a2d811c6e6b607e57)
|
||||
(downloadTable.parent as ScrollPane).actor = downloadTable
|
||||
scrollOnlineMods.actor = onlineModsTable
|
||||
|
||||
// continue search unless last page was reached
|
||||
if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks)
|
||||
tryDownloadPage(pageNum + 1)
|
||||
}
|
||||
|
||||
private fun syncOnlineSelected(modName: String, button: Button) {
|
||||
private fun syncOnlineSelected(modName: String, button: ModDecoratedButton) {
|
||||
syncSelected(modName, button, installedModInfo, scrollInstalledMods)
|
||||
}
|
||||
private fun syncInstalledSelected(modName: String, button: Button) {
|
||||
private fun syncInstalledSelected(modName: String, button: ModDecoratedButton) {
|
||||
syncSelected(modName, button, onlineModInfo, scrollOnlineMods)
|
||||
}
|
||||
private fun syncSelected(modName: String, button: Button, modNameToData: HashMap<String, ModUIData>, scroll: ScrollPane) {
|
||||
private fun syncSelected(modName: String, button: ModDecoratedButton, modNameToData: HashMap<String, ModUIData>, scroll: ScrollPane) {
|
||||
// manage selection color for user selection
|
||||
lastSelectedButton?.color = Color.WHITE
|
||||
button.color = Color.BLUE
|
||||
@ -351,133 +349,14 @@ class ModManagementScreen(
|
||||
lastSyncMarkedButton?.color = Color.WHITE
|
||||
lastSyncMarkedButton = null
|
||||
// look for sync-able same mod in other list
|
||||
val modUIDataInOtherList = modNameToData[modName] ?: return
|
||||
// scroll into view
|
||||
scroll.scrollY = (modUIDataInOtherList.y + (modUIDataInOtherList.height - scroll.height) / 2).coerceIn(0f, scroll.maxY)
|
||||
val buttonInOtherList = modButtons[modNameToData[modName]] ?: return
|
||||
// scroll into view - we know the containing Tables all have cell default padding 10f
|
||||
scroll.scrollTo(0f, buttonInOtherList.y - 10f, scroll.actor.width, buttonInOtherList.height + 20f, true, false)
|
||||
// and color it so it's easier to find. ROYAL and SLATE too dark.
|
||||
modUIDataInOtherList.button.color = Color.valueOf("7499ab") // about halfway between royal and sky
|
||||
lastSyncMarkedButton = modUIDataInOtherList.button
|
||||
buttonInOtherList.color = Color.valueOf("7499ab") // about halfway between royal and sky
|
||||
lastSyncMarkedButton = buttonInOtherList
|
||||
}
|
||||
|
||||
/** Recreate the information part of the right-hand column
|
||||
* @param repo: the repository instance as received from the GitHub api
|
||||
*/
|
||||
private fun addModInfoToActionTable(repo: Github.Repo) {
|
||||
addModInfoToActionTable(
|
||||
repo.name, repo.html_url, repo.default_branch,
|
||||
repo.pushed_at, repo.owner.login, repo.size
|
||||
)
|
||||
}
|
||||
/** Recreate the information part of the right-hand column
|
||||
* @param modName: The mod name (name from the RuleSet)
|
||||
* @param modOptions: The ModOptions as enriched by us with GitHub metadata when originally downloaded
|
||||
*/
|
||||
private fun addModInfoToActionTable(modName: String, modOptions: ModOptions) {
|
||||
addModInfoToActionTable(
|
||||
modName,
|
||||
modOptions.modUrl,
|
||||
modOptions.defaultBranch,
|
||||
modOptions.lastUpdated,
|
||||
modOptions.author,
|
||||
modOptions.modSize
|
||||
)
|
||||
}
|
||||
|
||||
private val repoUrlToPreviewImage = HashMap<String, Texture?>()
|
||||
|
||||
private fun addModInfoToActionTable(
|
||||
modName: String,
|
||||
repoUrl: String,
|
||||
defaultBranch: String,
|
||||
updatedAt: String,
|
||||
author: String,
|
||||
modSize: Int
|
||||
) {
|
||||
// remember selected mod - for now needed only to display a background-fetched image while the user is watching
|
||||
|
||||
// Display metadata
|
||||
|
||||
val imageHolder = Table()
|
||||
|
||||
if (repoUrl.isEmpty())
|
||||
addLocalPreviewImage(imageHolder, modName)
|
||||
else
|
||||
addPreviewImage(imageHolder, repoUrl, defaultBranch)
|
||||
|
||||
modActionTable.add(imageHolder).row()
|
||||
|
||||
|
||||
if (author.isNotEmpty())
|
||||
modActionTable.add("Author: [$author]".toLabel()).row()
|
||||
if (modSize > 0) {
|
||||
if (modSize < 2048)
|
||||
modActionTable.add("Size: [$modSize] kB".toLabel()).padBottom(15f).row()
|
||||
else
|
||||
modActionTable.add("Size: [${modSize/1024}] MB".toLabel()).padBottom(15f).row()
|
||||
}
|
||||
|
||||
// offer link to open the repo itself in a browser
|
||||
if (repoUrl.isNotEmpty()) {
|
||||
modActionTable.add("Open Github page".toTextButton().onClick {
|
||||
Gdx.net.openURI(repoUrl)
|
||||
}).row()
|
||||
}
|
||||
|
||||
// display "updated" date
|
||||
if (updatedAt.isNotEmpty()) {
|
||||
val date = updatedAt.parseDate()
|
||||
val updateString = "{Updated}: " + date.formatDate()
|
||||
modActionTable.add(updateString.toLabel()).row()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTextureAsPreview(imageHolder: Table, texture: Texture) {
|
||||
val cell = imageHolder.add(Image(texture))
|
||||
val largestImageSize = max(texture.width, texture.height)
|
||||
if (largestImageSize > maxAllowedPreviewImageSize) {
|
||||
val resizeRatio = maxAllowedPreviewImageSize / largestImageSize
|
||||
cell.size(texture.width * resizeRatio, texture.height * resizeRatio)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPreviewImage(
|
||||
imageHolder: Table,
|
||||
repoUrl: String,
|
||||
defaultBranch: String
|
||||
) {
|
||||
if (!repoUrl.startsWith("http")) return // invalid url
|
||||
|
||||
|
||||
if (repoUrlToPreviewImage.containsKey(repoUrl)) {
|
||||
val texture = repoUrlToPreviewImage[repoUrl]
|
||||
if (texture != null) setTextureAsPreview(imageHolder, texture)
|
||||
return
|
||||
}
|
||||
|
||||
Concurrency.run {
|
||||
val imagePixmap = Github.tryGetPreviewImage(repoUrl, defaultBranch)
|
||||
|
||||
if (imagePixmap == null) {
|
||||
repoUrlToPreviewImage[repoUrl] = null
|
||||
return@run
|
||||
}
|
||||
Concurrency.runOnGLThread {
|
||||
val texture = Texture(imagePixmap)
|
||||
imagePixmap.dispose()
|
||||
repoUrlToPreviewImage[repoUrl] = texture
|
||||
setTextureAsPreview(imageHolder, texture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addLocalPreviewImage(imageHolder: Table, modName: String) {
|
||||
// No concurrency, order of magnitude 20ms
|
||||
val modFolder = Gdx.files.local("mods/$modName")
|
||||
val previewFile = modFolder.child("preview.jpg").takeIf { it.exists() }
|
||||
?: modFolder.child("preview.png").takeIf { it.exists() }
|
||||
?: return
|
||||
setTextureAsPreview(imageHolder, Texture(previewFile))
|
||||
}
|
||||
|
||||
/** Create the special "Download from URL" button */
|
||||
private fun getDownloadFromUrlButton(): TextButton {
|
||||
@ -512,44 +391,37 @@ class ModManagementScreen(
|
||||
return downloadButton
|
||||
}
|
||||
|
||||
private fun updateModInfo() {
|
||||
if (selectedMod != null) {
|
||||
modActionTable.clear()
|
||||
addModInfoToActionTable(selectedMod!!)
|
||||
}
|
||||
}
|
||||
|
||||
/** Used as onClick handler for the online Mod list buttons */
|
||||
private fun onlineButtonAction(repo: Github.Repo, button: Button) {
|
||||
private fun onlineButtonAction(repo: Github.Repo, button: ModDecoratedButton) {
|
||||
syncOnlineSelected(repo.name, button)
|
||||
showModDescription(repo.name)
|
||||
rightSideButton.isVisible = true
|
||||
rightSideButton.clearListeners()
|
||||
rightSideButton.enable()
|
||||
val label = if (installedModInfo[repo.name]?.state?.hasUpdate == true)
|
||||
"Update [${repo.name}]"
|
||||
else "Download [${repo.name}]"
|
||||
|
||||
if (!repo.hasUpdatedSize) {
|
||||
// Setting this later would mean a failed query is repeated on the next mod click,
|
||||
// and click-spamming would launch several github queries.
|
||||
repo.hasUpdatedSize = true
|
||||
Concurrency.run("GitHubParser") {
|
||||
try {
|
||||
val repoSize = Github.getRepoSize(repo)
|
||||
if (repoSize > 0f) {
|
||||
if (repoSize > -1) {
|
||||
launchOnGLThread {
|
||||
repo.size = repoSize.toInt()
|
||||
repo.hasUpdatedSize = true
|
||||
repo.size = repoSize
|
||||
if (selectedMod == repo)
|
||||
updateModInfo()
|
||||
modActionTable.updateSize(repoSize)
|
||||
}
|
||||
}
|
||||
} catch (ignore: IOException) {
|
||||
/* Parsing of mod size failed, do nothing */
|
||||
/* Parsing of mod size failed, do nothing */
|
||||
}
|
||||
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
|
||||
rightSideButton.isVisible = true
|
||||
rightSideButton.enable()
|
||||
val label = if (installedModInfo[repo.name]?.hasUpdate == true) "Update [${repo.name}]"
|
||||
else "Download [${repo.name}]"
|
||||
rightSideButton.setText(label.tr())
|
||||
rightSideButton.clearActivationActions(ActivationTypes.Tap)
|
||||
rightSideButton.onClick {
|
||||
rightSideButton.setText("Downloading...".tr())
|
||||
rightSideButton.disable()
|
||||
@ -559,7 +431,7 @@ class ModManagementScreen(
|
||||
}
|
||||
|
||||
selectedMod = repo
|
||||
updateModInfo()
|
||||
modActionTable.update(repo)
|
||||
}
|
||||
|
||||
/** Download and install a mod in the background, called both from the right-bottom button and the URL entry popup */
|
||||
@ -579,7 +451,7 @@ class ModManagementScreen(
|
||||
TileSetCache.loadTileSetConfigs()
|
||||
UncivGame.Current.translations.tryReadTranslationForCurrentLanguage()
|
||||
RulesetCache[repoName]?.let {
|
||||
installedModInfo[repoName] = ModUIData(it)
|
||||
installedModInfo[repoName] = ModUIData(it, false)
|
||||
}
|
||||
refreshInstalledModTable()
|
||||
lastSelectedButton?.let { syncOnlineSelected(repoName, it) }
|
||||
@ -604,10 +476,14 @@ class ModManagementScreen(
|
||||
* (called under postRunnable posted by background thread)
|
||||
*/
|
||||
private fun unMarkUpdatedMod(name: String) {
|
||||
installedModInfo[name]?.state?.hasUpdate = false
|
||||
onlineModInfo[name]?.state?.hasUpdate = false
|
||||
val button = onlineModInfo[name]?.button
|
||||
button?.setText(name)
|
||||
installedModInfo[name]?.run {
|
||||
hasUpdate = false
|
||||
modButtons[this]?.updateIndicators()
|
||||
}
|
||||
onlineModInfo[name]?.run {
|
||||
hasUpdate = false
|
||||
modButtons[this]?.setText(name)
|
||||
}
|
||||
if (optionsManager.sortInstalled == SortType.Status)
|
||||
refreshInstalledModTable()
|
||||
if (optionsManager.sortOnline == SortType.Status)
|
||||
@ -619,88 +495,76 @@ class ModManagementScreen(
|
||||
*/
|
||||
private fun refreshInstalledModActions(mod: Ruleset) {
|
||||
selectedMod = null
|
||||
modActionTable.clear()
|
||||
// show mod information first
|
||||
addModInfoToActionTable(mod.name, mod.modOptions)
|
||||
modActionTable.update(mod)
|
||||
|
||||
val modInfo = installedModInfo[mod.name]!!
|
||||
|
||||
// offer 'permanent visual mod' toggle
|
||||
val visualMods = game.settings.visualMods
|
||||
val isVisualMod = visualMods.contains(mod.name)
|
||||
installedModInfo[mod.name]!!.state.isVisual = isVisualMod
|
||||
val isVisualMod = game.settings.visualMods.contains(mod.name)
|
||||
if (modInfo.isVisual != isVisualMod) {
|
||||
modInfo.isVisual = isVisualMod
|
||||
modButtons[modInfo]?.updateIndicators()
|
||||
}
|
||||
|
||||
val visualCheckBox = "Permanent audiovisual mod".toCheckBox(isVisualMod) { checked ->
|
||||
modActionTable.addVisualCheckBox(isVisualMod) { checked ->
|
||||
if (checked)
|
||||
visualMods.add(mod.name)
|
||||
game.settings.visualMods.add(mod.name)
|
||||
else
|
||||
visualMods.remove(mod.name)
|
||||
game.settings.visualMods.remove(mod.name)
|
||||
game.settings.save()
|
||||
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
||||
refreshInstalledModActions(mod)
|
||||
if (optionsManager.sortInstalled == SortType.Status)
|
||||
refreshInstalledModTable()
|
||||
}
|
||||
modActionTable.add(visualCheckBox).row()
|
||||
|
||||
if (installedModInfo[mod.name]!!.state.hasUpdate) {
|
||||
val updateModTextbutton = "Update [${mod.name}]".toTextButton()
|
||||
updateModTextbutton.onClick {
|
||||
updateModTextbutton.setText("Downloading...".tr())
|
||||
val repo = onlineModInfo[mod.name]!!.repo!!
|
||||
downloadMod(repo) { refreshInstalledModActions(mod) }
|
||||
}
|
||||
modActionTable.add(updateModTextbutton)
|
||||
modActionTable.addUpdateModButton(modInfo) {
|
||||
val repo = onlineModInfo[mod.name]!!.repo!!
|
||||
downloadMod(repo) { refreshInstalledModActions(mod) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild the metadata on installed mods */
|
||||
private fun refreshInstalledModInfo() {
|
||||
installedModInfo.clear()
|
||||
for (mod in RulesetCache.values.asSequence().filter { it.name != "" }) {
|
||||
installedModInfo[mod.name] = ModUIData(mod, mod.name in game.settings.visualMods)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCachedModButton(mod: ModUIData) = modButtons.getOrPut(mod) {
|
||||
val newButton = ModDecoratedButton(mod)
|
||||
if (mod.isInstalled) newButton.onClick { installedButtonAction(mod, newButton) }
|
||||
else newButton.onClick { onlineButtonAction(mod.repo!!, newButton) }
|
||||
newButton
|
||||
}
|
||||
|
||||
/** Rebuild the left-hand column containing all installed mods */
|
||||
internal fun refreshInstalledModTable() {
|
||||
// pre-init if not already done - important: keep the ModUIData instances later on or
|
||||
// at least the button references otherwise sync will not work
|
||||
if (installedModInfo.isEmpty()) {
|
||||
for (mod in RulesetCache.values.asSequence().filter { it.name != "" }) {
|
||||
val modUIData = ModUIData(mod)
|
||||
modUIData.state.isVisual = mod.name in game.settings.visualMods
|
||||
installedModInfo[mod.name] = modUIData
|
||||
}
|
||||
}
|
||||
|
||||
val newHeaderText = optionsManager.getInstalledHeader()
|
||||
installedHeaderLabel?.setText(newHeaderText)
|
||||
installedExpanderTab?.setText(newHeaderText)
|
||||
|
||||
modTable.clear()
|
||||
var currentY = -1f
|
||||
installedModsTable.clear()
|
||||
val filter = optionsManager.getFilter()
|
||||
for (mod in installedModInfo.values.sortedWith(optionsManager.sortInstalled.comparator)) {
|
||||
if (!mod.matchesFilter(filter)) continue
|
||||
// Prevent building up listeners. The virgin Button has one: for mouseover styling.
|
||||
// The captures for our listener shouldn't need updating, so assign only once
|
||||
if (mod.button.listeners.none { it.javaClass.`package`.name.startsWith("com.unciv") })
|
||||
mod.button.onClick {
|
||||
rightSideButton.isVisible = true
|
||||
installedButtonAction(mod)
|
||||
}
|
||||
val decoratedButton = Table()
|
||||
decoratedButton.add(mod.button)
|
||||
decoratedButton.add(mod.state.container).align(Align.center+Align.left)
|
||||
val cell = modTable.add(decoratedButton)
|
||||
modTable.row()
|
||||
if (currentY < 0f) currentY = cell.padTop
|
||||
mod.y = currentY
|
||||
mod.height = cell.prefHeight
|
||||
currentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||
installedModsTable.add(getCachedModButton(mod)).row()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installedButtonAction(mod: ModUIData) {
|
||||
syncInstalledSelected(mod.name, mod.button)
|
||||
private fun installedButtonAction(mod: ModUIData, button: ModDecoratedButton) {
|
||||
rightSideButton.isVisible = true
|
||||
|
||||
syncInstalledSelected(mod.name, button)
|
||||
refreshInstalledModActions(mod.ruleset!!)
|
||||
val deleteText = "Delete [${mod.name}]"
|
||||
rightSideButton.setText(deleteText.tr())
|
||||
// Don't let the player think he can delete Vanilla and G&K rulesets
|
||||
rightSideButton.isEnabled = mod.ruleset.folderLocation!=null
|
||||
showModDescription(mod.name)
|
||||
rightSideButton.clearListeners()
|
||||
rightSideButton.clearActivationActions(ActivationTypes.Tap) // clearListeners would also kill mouseover styling
|
||||
rightSideButton.onClick {
|
||||
rightSideButton.isEnabled = false
|
||||
ConfirmPopup(
|
||||
@ -735,9 +599,8 @@ class ModManagementScreen(
|
||||
onlineHeaderLabel?.setText(newHeaderText)
|
||||
onlineExpanderTab?.setText(newHeaderText)
|
||||
|
||||
downloadTable.clear()
|
||||
downloadTable.add(getDownloadFromUrlButton()).row()
|
||||
onlineScrollCurrentY = -1f
|
||||
onlineModsTable.clear()
|
||||
onlineModsTable.add(getDownloadFromUrlButton()).row()
|
||||
|
||||
val filter = optionsManager.getFilter()
|
||||
// Important: sortedMods holds references to the original values, so the referenced buttons stay valid.
|
||||
@ -745,16 +608,11 @@ class ModManagementScreen(
|
||||
val sortedMods = onlineModInfo.values.asSequence().sortedWith(optionsManager.sortOnline.comparator)
|
||||
for (mod in sortedMods) {
|
||||
if (!mod.matchesFilter(filter)) continue
|
||||
val cell = downloadTable.add(mod.button)
|
||||
downloadTable.row()
|
||||
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
|
||||
mod.y = onlineScrollCurrentY
|
||||
mod.height = cell.prefHeight
|
||||
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||
onlineModsTable.add(getCachedModButton(mod)).row()
|
||||
}
|
||||
|
||||
downloadTable.pack()
|
||||
(downloadTable.parent as ScrollPane).actor = downloadTable
|
||||
onlineModsTable.pack()
|
||||
scrollOnlineMods.actor = onlineModsTable
|
||||
}
|
||||
|
||||
private fun showModDescription(modName: String) {
|
||||
|
82
core/src/com/unciv/ui/screens/modmanager/ModUIData.kt
Normal file
82
core/src/com/unciv/ui/screens/modmanager/ModUIData.kt
Normal file
@ -0,0 +1,82 @@
|
||||
package com.unciv.ui.screens.modmanager
|
||||
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.unciv.models.metadata.ModCategories
|
||||
import com.unciv.models.ruleset.Ruleset
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.screens.pickerscreens.Github
|
||||
|
||||
/** Helper class holds combined mod info for ModManagementScreen, used for both installed and online lists.
|
||||
*
|
||||
* Contains metadata only, some preformatted for the UI, but no Gdx actors!
|
||||
* (This is important on resize - ModUIData are passed to the new screen)
|
||||
* Note it is guaranteed either ruleset or repo are non-null, never both.
|
||||
*/
|
||||
internal class ModUIData private constructor(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val ruleset: Ruleset?,
|
||||
val repo: Github.Repo?,
|
||||
var isVisual: Boolean = false,
|
||||
var hasUpdate: Boolean = false
|
||||
) {
|
||||
constructor(ruleset: Ruleset, isVisual: Boolean): this (
|
||||
ruleset.name,
|
||||
ruleset.getSummary().let {
|
||||
"Installed".tr() + (if (it.isEmpty()) "" else ": $it")
|
||||
},
|
||||
ruleset, null, isVisual = isVisual
|
||||
)
|
||||
|
||||
constructor(repo: Github.Repo, isUpdated: Boolean): this (
|
||||
repo.name,
|
||||
(repo.description ?: "-{No description provided}-".tr()) +
|
||||
"\n" + "[${repo.stargazers_count}]✯".tr(),
|
||||
null, repo, hasUpdate = isUpdated
|
||||
)
|
||||
|
||||
val isInstalled get() = ruleset != null
|
||||
fun lastUpdated() = ruleset?.modOptions?.lastUpdated ?: repo?.pushed_at ?: ""
|
||||
fun stargazers() = repo?.stargazers_count ?: 0
|
||||
fun author() = ruleset?.modOptions?.author ?: repo?.owner?.login ?: ""
|
||||
fun topics() = ruleset?.modOptions?.topics ?: repo?.topics ?: emptyList()
|
||||
fun buttonText() = when {
|
||||
ruleset != null -> ruleset.name
|
||||
repo != null -> repo.name + (if (hasUpdate) " - {Updated}" else "")
|
||||
else -> ""
|
||||
}
|
||||
|
||||
fun matchesFilter(filter: ModManagementOptions.Filter): Boolean = when {
|
||||
!matchesCategory(filter) -> false
|
||||
filter.text.isEmpty() -> true
|
||||
name.contains(filter.text, true) -> true
|
||||
// description.contains(filterText, true) -> true // too many surprises as description is different in the two columns
|
||||
author().contains(filter.text, true) -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun matchesCategory(filter: ModManagementOptions.Filter): Boolean {
|
||||
if (filter.topic == ModCategories.default().topic)
|
||||
return true
|
||||
val modTopics = repo?.topics ?: ruleset?.modOptions?.topics!!
|
||||
return filter.topic in modTopics
|
||||
}
|
||||
|
||||
fun stateSortWeight() = when {
|
||||
hasUpdate && isVisual -> 3
|
||||
hasUpdate -> 2
|
||||
isVisual -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// Equality contract required to use this as HashMap key
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ModUIData) return false
|
||||
return other.isInstalled == isInstalled && other.name == name
|
||||
}
|
||||
|
||||
override fun hashCode() = name.hashCode() * (if (isInstalled) 31 else 19)
|
||||
}
|
@ -259,12 +259,26 @@ object Github {
|
||||
return null
|
||||
}
|
||||
|
||||
fun tryGetPreviewImage(modUrl:String, defaultBranch: String): Pixmap? {
|
||||
/** Get a Pixmap from a "preview" png or jpg file at the root of the repo, falling back to the
|
||||
* repo owner's avatar [avatarUrl]. The file content url is constructed from [modUrl] and [defaultBranch]
|
||||
* by replacing the host with `raw.githubusercontent.com`.
|
||||
*/
|
||||
fun tryGetPreviewImage(modUrl: String, defaultBranch: String, avatarUrl: String?): Pixmap? {
|
||||
// Side note: github repos also have a "Social Preview" optionally assignable on the repo's
|
||||
// settings page, but that info is inaccessible using the v3 API anonymously. The easiest way
|
||||
// to get it would be to query the the repo's frontend page (modUrl), and parse out
|
||||
// `head/meta[property=og:image]/@content`, which is one extra spurious roundtrip and a
|
||||
// non-trivial waste of bandwidth.
|
||||
// Thus we ask for a "preview" file as part of the repo contents instead.
|
||||
val fileLocation = "$modUrl/$defaultBranch/preview"
|
||||
.replace("github.com", "raw.githubusercontent.com")
|
||||
try {
|
||||
val file = download("$fileLocation.jpg")
|
||||
?: download("$fileLocation.png")
|
||||
// Note: avatar urls look like: https://avatars.githubusercontent.com/u/<number>?v=4
|
||||
// So the image format is only recognizable from the response "Content-Type" header
|
||||
// or by looking for magic markers in the bits - which the Pixmap constructor below does.
|
||||
?: avatarUrl?.let { download(it) }
|
||||
?: return null
|
||||
val byteArray = file.readBytes()
|
||||
val buffer = ByteBuffer.allocateDirect(byteArray.size).put(byteArray).position(0)
|
||||
@ -274,25 +288,38 @@ object Github {
|
||||
}
|
||||
}
|
||||
|
||||
class Tree {
|
||||
/** Class to receive a github API "Get a tree" response parsed as json */
|
||||
// Parts of the response we ignore are commented out
|
||||
private class Tree {
|
||||
//val sha = ""
|
||||
//val url = ""
|
||||
|
||||
class TreeFile {
|
||||
//val path = ""
|
||||
//val mode = 0
|
||||
//val type = "" // blob / tree
|
||||
//val sha = ""
|
||||
//val url = ""
|
||||
var size: Long = 0L
|
||||
}
|
||||
|
||||
var url: String = ""
|
||||
@Suppress("MemberNameEqualsClassName")
|
||||
var tree = ArrayList<TreeFile>()
|
||||
var truncated = false
|
||||
}
|
||||
|
||||
fun getRepoSize(repo: Repo): Float {
|
||||
/** Queries github for a tree and calculates the sum of the blob sizes.
|
||||
* @return -1 on failure, else size rounded to kB
|
||||
*/
|
||||
fun getRepoSize(repo: Repo): Int {
|
||||
// See https://docs.github.com/en/rest/git/trees#get-a-tree
|
||||
val link = "https://api.github.com/repos/${repo.full_name}/git/trees/${repo.default_branch}?recursive=true"
|
||||
|
||||
var retries = 2
|
||||
while (retries > 0) {
|
||||
retries--
|
||||
// obey rate limit
|
||||
if (RateLimit.waitForLimit()) return 0f
|
||||
if (RateLimit.waitForLimit()) return -1
|
||||
// try download
|
||||
val inputStream = download(link) {
|
||||
if (it.responseCode == 403 || it.responseCode == 200 && retries == 1) {
|
||||
@ -301,15 +328,18 @@ object Github {
|
||||
retries++ // An extra retry so the 403 is ignored in the retry count
|
||||
}
|
||||
} ?: continue
|
||||
|
||||
val tree = json().fromJson(Tree::class.java, inputStream.bufferedReader().readText())
|
||||
if (tree.truncated) return -1 // unlikely: >100k blobs or blob > 7MB
|
||||
|
||||
var totalSizeBytes = 0L
|
||||
for (file in tree.tree)
|
||||
totalSizeBytes += file.size
|
||||
|
||||
return totalSizeBytes / 1024f
|
||||
// overflow unlikely: >2TB
|
||||
return ((totalSizeBytes + 512) / 1024).toInt()
|
||||
}
|
||||
return 0f
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
@ -331,6 +361,8 @@ object Github {
|
||||
@Suppress("PropertyName")
|
||||
class Repo {
|
||||
|
||||
/** Unlike the rest of this class, this is not part of the API but added by us locally
|
||||
* to track whether [getRepoSize] has been run successfully for this repo */
|
||||
var hasUpdatedSize = false
|
||||
|
||||
var name = ""
|
||||
|
Loading…
Reference in New Issue
Block a user