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:
SomeTroglodyte 2023-08-14 15:17:18 +02:00 committed by GitHub
parent 756431ee74
commit a4a43dadc1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 511 additions and 404 deletions

View File

@ -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
}
}

View 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)
}
}
}

View File

@ -2,28 +2,22 @@ package com.unciv.ui.screens.modmanager
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Touchable 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.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.Constants import com.unciv.Constants
import com.unciv.models.metadata.ModCategories import com.unciv.models.metadata.ModCategories
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.translations.tr 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.ExpanderTab
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip 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.keyShortcuts
import com.unciv.ui.components.input.onActivation import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onChange import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.extensions.surroundWithCircle import com.unciv.ui.images.ImageGetter
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox
import com.unciv.ui.screens.pickerscreens.Github
import kotlin.math.sign import kotlin.math.sign
/** /**
@ -33,7 +27,7 @@ import kotlin.math.sign
* It holds the variables [sortInstalled] and [sortOnline] for the [modManagementScreen] and knows * It holds the variables [sortInstalled] and [sortOnline] for the [modManagementScreen] and knows
* how to sort collections of [ModUIData] by providing comparators. * how to sort collections of [ModUIData] by providing comparators.
*/ */
class ModManagementOptions(private val modManagementScreen: ModManagementScreen) { internal class ModManagementOptions(private val modManagementScreen: ModManagementScreen) {
companion object { companion object {
val sortByName = Comparator { mod1, mod2: ModUIData -> mod1.name.compareTo(mod2.name, true) } val sortByName = Comparator { mod1, mod2: ModUIData -> mod1.name.compareTo(mod2.name, true) }
val sortByNameDesc = Comparator { mod1, mod2: ModUIData -> mod2.name.compareTo(mod1.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 10 * (mod2.stargazers() - mod1.stargazers()) + mod1.name.compareTo(mod2.name, true).sign
} }
val sortByStatus = Comparator { mod1, mod2: ModUIData -> 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" const val installedHeaderText = "Current mods"
@ -190,112 +184,3 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
modManagementScreen.refreshOnlineModTable() 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
}
}

View File

@ -2,11 +2,8 @@ package com.unciv.ui.screens.modmanager
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Color 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.Actor
import com.badlogic.gdx.scenes.scene2d.Touchable 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.Label
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table 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.UncivGame
import com.unciv.json.fromJsonFile import com.unciv.json.fromJsonFile
import com.unciv.json.json import com.unciv.json.json
import com.unciv.models.ruleset.ModOptions
import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.tilesets.TileSetCache import com.unciv.models.tilesets.TileSetCache
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.AutoScrollPane import com.unciv.ui.components.AutoScrollPane
import com.unciv.ui.components.ExpanderTab import com.unciv.ui.components.ExpanderTab
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.UncivTextField import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.WrappableLabel 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.addSeparator
import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.isEnabled 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.keyShortcuts
import com.unciv.ui.components.input.onActivation import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick 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.images.ImageGetter
import com.unciv.ui.popups.ConfirmPopup import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.popups.Popup 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.BaseScreen
import com.unciv.ui.screens.basescreen.RecreateOnResize import com.unciv.ui.screens.basescreen.RecreateOnResize
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen 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.modmanager.ModManagementOptions.SortType
import com.unciv.ui.screens.pickerscreens.Github 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.ui.screens.pickerscreens.PickerScreen
import com.unciv.utils.Concurrency import com.unciv.utils.Concurrency
import com.unciv.utils.Log import com.unciv.utils.Log
@ -58,15 +53,19 @@ import java.io.IOException
import kotlin.math.max import kotlin.math.max
/** /**
* The Mod Management Screen - called only from [MainMenuScreen] * The Mod Management Screen - constructor for internal use by [resize]
* @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. * @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. // 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. // Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
class ModManagementScreen( class ModManagementScreen private constructor(
previousInstalledMods: HashMap<String, ModUIData>? = null, previousInstalledMods: HashMap<String, ModUIData>?,
previousOnlineMods: HashMap<String, ModUIData>? = null previousOnlineMods: HashMap<String, ModUIData>?
): PickerScreen(disableScroll = true), RecreateOnResize { ): PickerScreen(disableScroll = true), RecreateOnResize {
/** The Mod Management Screen - called only from [MainMenuScreen] */
constructor() : this(null, null)
companion object { companion object {
// Tweakable constants // Tweakable constants
/** For preview.png */ /** For preview.png */
@ -80,15 +79,20 @@ class ModManagementScreen(
} }
} }
private val modTable = Table().apply { defaults().pad(10f) } // Left column (in landscape, portrait stacks them within expanders)
private val scrollInstalledMods = AutoScrollPane(modTable) private val installedModsTable = Table().apply { defaults().pad(10f) }
private val downloadTable = Table().apply { defaults().pad(10f) } private val scrollInstalledMods = AutoScrollPane(installedModsTable)
private val scrollOnlineMods = AutoScrollPane(downloadTable) // Center column
private val modActionTable = Table().apply { defaults().pad(10f) } 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 val optionsManager = ModManagementOptions(this)
private var lastSelectedButton: Button? = null private var lastSelectedButton: ModDecoratedButton? = null
private var lastSyncMarkedButton: Button? = null private var lastSyncMarkedButton: ModDecoratedButton? = null
private var selectedMod: Github.Repo? = null private var selectedMod: Github.Repo? = null
private val modDescriptionLabel: WrappableLabel private val modDescriptionLabel: WrappableLabel
@ -98,12 +102,11 @@ class ModManagementScreen(
private var installedExpanderTab: ExpanderTab? = null private var installedExpanderTab: ExpanderTab? = null
private var onlineExpanderTab: ExpanderTab? = null private var onlineExpanderTab: ExpanderTab? = null
// Enable re-sorting and syncing entries in 'installed' and 'repo search' ScrollPanes // 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 installedModInfo = previousInstalledMods ?: HashMap(10) // HashMap<String, ModUIData> inferred
private val onlineModInfo = previousOnlineMods ?: HashMap(90) // HashMap<String, ModUIData> inferred private val onlineModInfo = previousOnlineMods ?: HashMap(90) // HashMap<String, ModUIData> inferred
private val modButtons: HashMap<ModUIData, ModDecoratedButton> = HashMap(100)
private var onlineScrollCurrentY = -1f
// cleanup - background processing needs to be stopped on exit and memory freed // cleanup - background processing needs to be stopped on exit and memory freed
private var runningSearchJob: Job? = null private var runningSearchJob: Job? = null
@ -118,7 +121,11 @@ class ModManagementScreen(
init { 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 rightSideButton.isVisible = false
closeButton.onActivation { closeButton.onActivation {
val tileSets = ImageGetter.getAvailableTilesets() val tileSets = ImageGetter.getAvailableTilesets()
@ -142,18 +149,18 @@ class ModManagementScreen(
labelWrapper.add(modDescriptionLabel).row() labelWrapper.add(modDescriptionLabel).row()
labelScroll.actor = labelWrapper labelScroll.actor = labelWrapper
refreshInstalledModTable()
if (isNarrowerThan4to3()) initPortrait() if (isNarrowerThan4to3()) initPortrait()
else initLandscape() else initLandscape()
if (installedModInfo.isEmpty())
refreshInstalledModInfo()
refreshInstalledModTable()
if (onlineModInfo.isEmpty()) if (onlineModInfo.isEmpty())
reloadOnlineMods() reloadOnlineMods()
else else
refreshOnlineModTable() 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() { private fun initPortrait() {
@ -162,19 +169,19 @@ class ModManagementScreen(
topTable.add(optionsManager.expander).top().growX().row() topTable.add(optionsManager.expander).top().growX().row()
installedExpanderTab = ExpanderTab(optionsManager.getInstalledHeader(), expanderWidth = stage.width) { 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() topTable.add(installedExpanderTab).top().growX().row()
onlineExpanderTab = ExpanderTab(optionsManager.getOnlineHeader(), expanderWidth = stage.width) { 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(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) { 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() }).bottom().padTop(10f).growX().row()
} }
@ -186,26 +193,23 @@ class ModManagementScreen(
optionsManager.installedHeaderClicked() optionsManager.installedHeaderClicked()
} }
topTable.add(installedHeaderLabel).pad(15f).minWidth(200f).padLeft(25f) 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 = optionsManager.getOnlineHeader().toLabel()
onlineHeaderLabel!!.onClick { onlineHeaderLabel!!.onClick {
optionsManager.onlineHeaderClicked() optionsManager.onlineHeaderClicked()
} }
topTable.add(onlineHeaderLabel).pad(15f) topTable.add(onlineHeaderLabel).pad(15f)
topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions" topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions"
topTable.add().expandX() topTable.add().expandX().row()
topTable.row()
// horizontal separator looking like the SplitPane handle // horizontal separator looking like the SplitPane handle
topTable.addSeparator(Color.CLEAR, 5, 3f) topTable.addSeparator(Color.CLEAR, 5, 3f)
// main row containing the three 'blocks' installed, online and information // 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(scrollInstalledMods)
topTable.add(scrollOnlineMods) topTable.add(scrollOnlineMods)
topTable.add(modActionTable) topTable.add(scrollActionTable)
topTable.add().row() topTable.add().expandX().row()
topTable.add().expandY() // So short lists won't vertically center everything including headers
stage.addActor(optionsManager.expander) stage.addActor(optionsManager.expander)
optionsManager.expanderChangeEvent = { optionsManager.expanderChangeEvent = {
@ -216,11 +220,10 @@ class ModManagementScreen(
} }
private fun reloadOnlineMods() { private fun reloadOnlineMods() {
onlineScrollCurrentY = -1f onlineModsTable.clear()
downloadTable.clear()
onlineModInfo.clear() onlineModInfo.clear()
downloadTable.add(getDownloadFromUrlButton()).padBottom(15f).row() onlineModsTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
downloadTable.add("...".toLabel()).row() onlineModsTable.add("...".toLabel()).row()
tryDownloadPage(1) tryDownloadPage(1)
} }
@ -253,10 +256,10 @@ class ModManagementScreen(
private fun addModInfoFromRepoSearch(repoSearch: Github.RepoSearch, pageNum: Int){ private fun addModInfoFromRepoSearch(repoSearch: Github.RepoSearch, pageNum: Int){
// clear and remove last cell if it is the "..." indicator // 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() == "...") { if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
lastCell.setActor<Actor>(null) lastCell.setActor<Actor>(null)
downloadTable.cells.removeValue(lastCell, true) onlineModsTable.cells.removeValue(lastCell, true)
} }
for (repo in repoSearch.items) { for (repo in repoSearch.items) {
@ -279,7 +282,9 @@ class ModManagementScreen(
if (installedMod != null) { if (installedMod != null) {
if (isUpdatedVersionOfInstalledMod) { if (isUpdatedVersionOfInstalledMod) {
installedModInfo[repo.name]!!.state.hasUpdate = true val modInfo = installedModInfo[repo.name]!!
modInfo.hasUpdate = true
modButtons[modInfo]?.updateIndicators()
} }
if (installedMod.modOptions.author.isEmpty()) { if (installedMod.modOptions.author.isEmpty()) {
@ -300,14 +305,7 @@ class ModManagementScreen(
val mod = ModUIData(repo, isUpdatedVersionOfInstalledMod) val mod = ModUIData(repo, isUpdatedVersionOfInstalledMod)
onlineModInfo[repo.name] = mod onlineModInfo[repo.name] = mod
mod.button.onClick { onlineButtonAction(repo, mod.button) } onlineModsTable.add(getCachedModButton(mod)).row()
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
} }
// Now the tasks after the 'page' of search results has been fully processed // 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) val retryLabel = "Online query result is incomplete".toLabel(Color.RED)
retryLabel.touchable = Touchable.enabled retryLabel.touchable = Touchable.enabled
retryLabel.onClick { reloadOnlineMods() } retryLabel.onClick { reloadOnlineMods() }
downloadTable.add(retryLabel) onlineModsTable.add(retryLabel)
} }
} else { } else {
// the page was full so there may be more pages. // the page was full so there may be more pages.
// indicate that search will be continued // 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. // 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) // 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 // continue search unless last page was reached
if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks) if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks)
tryDownloadPage(pageNum + 1) tryDownloadPage(pageNum + 1)
} }
private fun syncOnlineSelected(modName: String, button: Button) { private fun syncOnlineSelected(modName: String, button: ModDecoratedButton) {
syncSelected(modName, button, installedModInfo, scrollInstalledMods) syncSelected(modName, button, installedModInfo, scrollInstalledMods)
} }
private fun syncInstalledSelected(modName: String, button: Button) { private fun syncInstalledSelected(modName: String, button: ModDecoratedButton) {
syncSelected(modName, button, onlineModInfo, scrollOnlineMods) 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 // manage selection color for user selection
lastSelectedButton?.color = Color.WHITE lastSelectedButton?.color = Color.WHITE
button.color = Color.BLUE button.color = Color.BLUE
@ -351,133 +349,14 @@ class ModManagementScreen(
lastSyncMarkedButton?.color = Color.WHITE lastSyncMarkedButton?.color = Color.WHITE
lastSyncMarkedButton = null lastSyncMarkedButton = null
// look for sync-able same mod in other list // look for sync-able same mod in other list
val modUIDataInOtherList = modNameToData[modName] ?: return val buttonInOtherList = modButtons[modNameToData[modName]] ?: return
// scroll into view // scroll into view - we know the containing Tables all have cell default padding 10f
scroll.scrollY = (modUIDataInOtherList.y + (modUIDataInOtherList.height - scroll.height) / 2).coerceIn(0f, scroll.maxY) 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. // 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 buttonInOtherList.color = Color.valueOf("7499ab") // about halfway between royal and sky
lastSyncMarkedButton = modUIDataInOtherList.button 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 */ /** Create the special "Download from URL" button */
private fun getDownloadFromUrlButton(): TextButton { private fun getDownloadFromUrlButton(): TextButton {
@ -512,44 +391,37 @@ class ModManagementScreen(
return downloadButton return downloadButton
} }
private fun updateModInfo() {
if (selectedMod != null) {
modActionTable.clear()
addModInfoToActionTable(selectedMod!!)
}
}
/** Used as onClick handler for the online Mod list buttons */ /** 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) syncOnlineSelected(repo.name, button)
showModDescription(repo.name) 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) { 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") { Concurrency.run("GitHubParser") {
try { try {
val repoSize = Github.getRepoSize(repo) val repoSize = Github.getRepoSize(repo)
if (repoSize > 0f) { if (repoSize > -1) {
launchOnGLThread { launchOnGLThread {
repo.size = repoSize.toInt() repo.size = repoSize
repo.hasUpdatedSize = true
if (selectedMod == repo) if (selectedMod == repo)
updateModInfo() modActionTable.updateSize(repoSize)
} }
} }
} catch (ignore: IOException) { } 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.setText(label.tr())
rightSideButton.clearActivationActions(ActivationTypes.Tap)
rightSideButton.onClick { rightSideButton.onClick {
rightSideButton.setText("Downloading...".tr()) rightSideButton.setText("Downloading...".tr())
rightSideButton.disable() rightSideButton.disable()
@ -559,7 +431,7 @@ class ModManagementScreen(
} }
selectedMod = repo 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 */ /** 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() TileSetCache.loadTileSetConfigs()
UncivGame.Current.translations.tryReadTranslationForCurrentLanguage() UncivGame.Current.translations.tryReadTranslationForCurrentLanguage()
RulesetCache[repoName]?.let { RulesetCache[repoName]?.let {
installedModInfo[repoName] = ModUIData(it) installedModInfo[repoName] = ModUIData(it, false)
} }
refreshInstalledModTable() refreshInstalledModTable()
lastSelectedButton?.let { syncOnlineSelected(repoName, it) } lastSelectedButton?.let { syncOnlineSelected(repoName, it) }
@ -604,10 +476,14 @@ class ModManagementScreen(
* (called under postRunnable posted by background thread) * (called under postRunnable posted by background thread)
*/ */
private fun unMarkUpdatedMod(name: String) { private fun unMarkUpdatedMod(name: String) {
installedModInfo[name]?.state?.hasUpdate = false installedModInfo[name]?.run {
onlineModInfo[name]?.state?.hasUpdate = false hasUpdate = false
val button = onlineModInfo[name]?.button modButtons[this]?.updateIndicators()
button?.setText(name) }
onlineModInfo[name]?.run {
hasUpdate = false
modButtons[this]?.setText(name)
}
if (optionsManager.sortInstalled == SortType.Status) if (optionsManager.sortInstalled == SortType.Status)
refreshInstalledModTable() refreshInstalledModTable()
if (optionsManager.sortOnline == SortType.Status) if (optionsManager.sortOnline == SortType.Status)
@ -619,88 +495,76 @@ class ModManagementScreen(
*/ */
private fun refreshInstalledModActions(mod: Ruleset) { private fun refreshInstalledModActions(mod: Ruleset) {
selectedMod = null selectedMod = null
modActionTable.clear()
// show mod information first // show mod information first
addModInfoToActionTable(mod.name, mod.modOptions) modActionTable.update(mod)
val modInfo = installedModInfo[mod.name]!!
// offer 'permanent visual mod' toggle // offer 'permanent visual mod' toggle
val visualMods = game.settings.visualMods val isVisualMod = game.settings.visualMods.contains(mod.name)
val isVisualMod = visualMods.contains(mod.name) if (modInfo.isVisual != isVisualMod) {
installedModInfo[mod.name]!!.state.isVisual = isVisualMod modInfo.isVisual = isVisualMod
modButtons[modInfo]?.updateIndicators()
}
val visualCheckBox = "Permanent audiovisual mod".toCheckBox(isVisualMod) { checked -> modActionTable.addVisualCheckBox(isVisualMod) { checked ->
if (checked) if (checked)
visualMods.add(mod.name) game.settings.visualMods.add(mod.name)
else else
visualMods.remove(mod.name) game.settings.visualMods.remove(mod.name)
game.settings.save() game.settings.save()
ImageGetter.setNewRuleset(ImageGetter.ruleset) ImageGetter.setNewRuleset(ImageGetter.ruleset)
refreshInstalledModActions(mod) refreshInstalledModActions(mod)
if (optionsManager.sortInstalled == SortType.Status) if (optionsManager.sortInstalled == SortType.Status)
refreshInstalledModTable() refreshInstalledModTable()
} }
modActionTable.add(visualCheckBox).row()
if (installedModInfo[mod.name]!!.state.hasUpdate) { modActionTable.addUpdateModButton(modInfo) {
val updateModTextbutton = "Update [${mod.name}]".toTextButton() val repo = onlineModInfo[mod.name]!!.repo!!
updateModTextbutton.onClick { downloadMod(repo) { refreshInstalledModActions(mod) }
updateModTextbutton.setText("Downloading...".tr())
val repo = onlineModInfo[mod.name]!!.repo!!
downloadMod(repo) { refreshInstalledModActions(mod) }
}
modActionTable.add(updateModTextbutton)
} }
} }
/** 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 */ /** Rebuild the left-hand column containing all installed mods */
internal fun refreshInstalledModTable() { 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() val newHeaderText = optionsManager.getInstalledHeader()
installedHeaderLabel?.setText(newHeaderText) installedHeaderLabel?.setText(newHeaderText)
installedExpanderTab?.setText(newHeaderText) installedExpanderTab?.setText(newHeaderText)
modTable.clear() installedModsTable.clear()
var currentY = -1f
val filter = optionsManager.getFilter() val filter = optionsManager.getFilter()
for (mod in installedModInfo.values.sortedWith(optionsManager.sortInstalled.comparator)) { for (mod in installedModInfo.values.sortedWith(optionsManager.sortInstalled.comparator)) {
if (!mod.matchesFilter(filter)) continue if (!mod.matchesFilter(filter)) continue
// Prevent building up listeners. The virgin Button has one: for mouseover styling. installedModsTable.add(getCachedModButton(mod)).row()
// 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
} }
} }
private fun installedButtonAction(mod: ModUIData) { private fun installedButtonAction(mod: ModUIData, button: ModDecoratedButton) {
syncInstalledSelected(mod.name, mod.button) rightSideButton.isVisible = true
syncInstalledSelected(mod.name, button)
refreshInstalledModActions(mod.ruleset!!) refreshInstalledModActions(mod.ruleset!!)
val deleteText = "Delete [${mod.name}]" val deleteText = "Delete [${mod.name}]"
rightSideButton.setText(deleteText.tr()) rightSideButton.setText(deleteText.tr())
// Don't let the player think he can delete Vanilla and G&K rulesets // Don't let the player think he can delete Vanilla and G&K rulesets
rightSideButton.isEnabled = mod.ruleset.folderLocation!=null rightSideButton.isEnabled = mod.ruleset.folderLocation!=null
showModDescription(mod.name) showModDescription(mod.name)
rightSideButton.clearListeners() rightSideButton.clearActivationActions(ActivationTypes.Tap) // clearListeners would also kill mouseover styling
rightSideButton.onClick { rightSideButton.onClick {
rightSideButton.isEnabled = false rightSideButton.isEnabled = false
ConfirmPopup( ConfirmPopup(
@ -735,9 +599,8 @@ class ModManagementScreen(
onlineHeaderLabel?.setText(newHeaderText) onlineHeaderLabel?.setText(newHeaderText)
onlineExpanderTab?.setText(newHeaderText) onlineExpanderTab?.setText(newHeaderText)
downloadTable.clear() onlineModsTable.clear()
downloadTable.add(getDownloadFromUrlButton()).row() onlineModsTable.add(getDownloadFromUrlButton()).row()
onlineScrollCurrentY = -1f
val filter = optionsManager.getFilter() val filter = optionsManager.getFilter()
// Important: sortedMods holds references to the original values, so the referenced buttons stay valid. // 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) val sortedMods = onlineModInfo.values.asSequence().sortedWith(optionsManager.sortOnline.comparator)
for (mod in sortedMods) { for (mod in sortedMods) {
if (!mod.matchesFilter(filter)) continue if (!mod.matchesFilter(filter)) continue
val cell = downloadTable.add(mod.button) onlineModsTable.add(getCachedModButton(mod)).row()
downloadTable.row()
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
mod.y = onlineScrollCurrentY
mod.height = cell.prefHeight
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
} }
downloadTable.pack() onlineModsTable.pack()
(downloadTable.parent as ScrollPane).actor = downloadTable scrollOnlineMods.actor = onlineModsTable
} }
private fun showModDescription(modName: String) { private fun showModDescription(modName: String) {

View 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)
}

View File

@ -259,12 +259,26 @@ object Github {
return null 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" val fileLocation = "$modUrl/$defaultBranch/preview"
.replace("github.com", "raw.githubusercontent.com") .replace("github.com", "raw.githubusercontent.com")
try { try {
val file = download("$fileLocation.jpg") val file = download("$fileLocation.jpg")
?: download("$fileLocation.png") ?: 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 ?: return null
val byteArray = file.readBytes() val byteArray = file.readBytes()
val buffer = ByteBuffer.allocateDirect(byteArray.size).put(byteArray).position(0) 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 { class TreeFile {
//val path = ""
//val mode = 0
//val type = "" // blob / tree
//val sha = ""
//val url = ""
var size: Long = 0L var size: Long = 0L
} }
var url: String = ""
@Suppress("MemberNameEqualsClassName") @Suppress("MemberNameEqualsClassName")
var tree = ArrayList<TreeFile>() 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" val link = "https://api.github.com/repos/${repo.full_name}/git/trees/${repo.default_branch}?recursive=true"
var retries = 2 var retries = 2
while (retries > 0) { while (retries > 0) {
retries-- retries--
// obey rate limit // obey rate limit
if (RateLimit.waitForLimit()) return 0f if (RateLimit.waitForLimit()) return -1
// try download // try download
val inputStream = download(link) { val inputStream = download(link) {
if (it.responseCode == 403 || it.responseCode == 200 && retries == 1) { 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 retries++ // An extra retry so the 403 is ignored in the retry count
} }
} ?: continue } ?: continue
val tree = json().fromJson(Tree::class.java, inputStream.bufferedReader().readText()) val tree = json().fromJson(Tree::class.java, inputStream.bufferedReader().readText())
if (tree.truncated) return -1 // unlikely: >100k blobs or blob > 7MB
var totalSizeBytes = 0L var totalSizeBytes = 0L
for (file in tree.tree) for (file in tree.tree)
totalSizeBytes += file.size 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") @Suppress("PropertyName")
class Repo { 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 hasUpdatedSize = false
var name = "" var name = ""