diff --git a/core/src/com/unciv/ui/screens/modmanager/ModDecoratedButton.kt b/core/src/com/unciv/ui/screens/modmanager/ModDecoratedButton.kt new file mode 100644 index 0000000000..5f00effa56 --- /dev/null +++ b/core/src/com/unciv/ui/screens/modmanager/ModDecoratedButton.kt @@ -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() + 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 + } +} diff --git a/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt b/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt new file mode 100644 index 0000000000..bb0b6d70ef --- /dev/null +++ b/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt @@ -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() + 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) + } + } +} diff --git a/core/src/com/unciv/ui/screens/modmanager/ModManagementOptions.kt b/core/src/com/unciv/ui/screens/modmanager/ModManagementOptions.kt index f9984df753..fdc957c529 100644 --- a/core/src/com/unciv/ui/screens/modmanager/ModManagementOptions.kt +++ b/core/src/com/unciv/ui/screens/modmanager/ModManagementOptions.kt @@ -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): TextButton { - val categories = ArrayList() - 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 - } -} diff --git a/core/src/com/unciv/ui/screens/modmanager/ModManagementScreen.kt b/core/src/com/unciv/ui/screens/modmanager/ModManagementScreen.kt index b6b4b6c0e7..7e190099b0 100644 --- a/core/src/com/unciv/ui/screens/modmanager/ModManagementScreen.kt +++ b/core/src/com/unciv/ui/screens/modmanager/ModManagementScreen.kt @@ -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? = null, - previousOnlineMods: HashMap? = null +class ModManagementScreen private constructor( + previousInstalledMods: HashMap?, + previousOnlineMods: HashMap? ): 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 inferred private val onlineModInfo = previousOnlineMods ?: HashMap(90) // HashMap inferred - - private var onlineScrollCurrentY = -1f + private val modButtons: HashMap = 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(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, scroll: ScrollPane) { + private fun syncSelected(modName: String, button: ModDecoratedButton, modNameToData: HashMap, 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() - - 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) { diff --git a/core/src/com/unciv/ui/screens/modmanager/ModUIData.kt b/core/src/com/unciv/ui/screens/modmanager/ModUIData.kt new file mode 100644 index 0000000000..e6ff5f32eb --- /dev/null +++ b/core/src/com/unciv/ui/screens/modmanager/ModUIData.kt @@ -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) +} diff --git a/core/src/com/unciv/ui/screens/pickerscreens/GitHub.kt b/core/src/com/unciv/ui/screens/pickerscreens/GitHub.kt index e3ecb14c7c..5b293398b3 100644 --- a/core/src/com/unciv/ui/screens/pickerscreens/GitHub.kt +++ b/core/src/com/unciv/ui/screens/pickerscreens/GitHub.kt @@ -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/?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() + 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 = ""