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

View File

@ -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) {

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
}
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 = ""