From 4b2f5e468d4129c901003f516315401e6593fce6 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Tue, 4 Jun 2024 17:00:04 +0200 Subject: [PATCH] Sortable unit overview (#11521) * SortableGrid architecture changes: defaultSort instead of defaultDescending * SortableGrid architecture changes: Header cell actor management * SortableGrid architecture changes: General Services and reusable defaults * Fix EmpireOverviewScreen sometimes forgetting the last active tab * Reimplement Unit Overview using SortableGrid (but dropping unit supply) * Get UnitSupplyTable back into Unit Overview * Fix unit overview does not know PromotionPickerScreen changed the name * Simplify update after rename * Fix "Ooops" wrong value in ranged strength column --- .../ISortableGridContentProvider.kt | 42 ++- .../ui/components/widgets/SortableGrid.kt | 220 ++++++++--- .../overviewscreen/CityOverviewTabColumn.kt | 61 +-- .../overviewscreen/EmpireOverviewTab.kt | 1 + .../screens/overviewscreen/UnitOverviewTab.kt | 351 +++--------------- .../overviewscreen/UnitOverviewTabColumn.kt | 143 +++++++ .../overviewscreen/UnitOverviewTabHelpers.kt | 151 ++++++++ .../screens/overviewscreen/UnitSupplyTable.kt | 81 ++++ .../pickerscreens/PromotionPickerScreen.kt | 25 +- 9 files changed, 672 insertions(+), 403 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabColumn.kt create mode 100644 core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabHelpers.kt create mode 100644 core/src/com/unciv/ui/screens/overviewscreen/UnitSupplyTable.kt diff --git a/core/src/com/unciv/ui/components/ISortableGridContentProvider.kt b/core/src/com/unciv/ui/components/ISortableGridContentProvider.kt index 4795dbdbd9..d3a7c8948e 100644 --- a/core/src/com/unciv/ui/components/ISortableGridContentProvider.kt +++ b/core/src/com/unciv/ui/components/ISortableGridContentProvider.kt @@ -1,15 +1,23 @@ package com.unciv.ui.components +import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.ui.Cell +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.utils.Align +import com.unciv.UncivGame import com.unciv.logic.GameInfo +import com.unciv.ui.components.extensions.surroundWithCircle +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.widgets.SortableGrid +import com.unciv.ui.images.ImageGetter /** * This defines all behaviour of a sortable Grid per column through overridable parts: * - [isVisible] can hide a column * - [align], [fillX], [expandX], [equalizeHeight] control geometry - * - [getComparator] or [getEntryValue] control sorting, [defaultDescending] the initial order - * - [getHeaderIcon], [headerTip] and [headerTipHideIcons] define how the header row looks + * - [getComparator] or [getEntryValue] control sorting, [defaultSort] the initial order + * - [getHeaderActor], [headerTip] and [headerTipHideIcons] define how the header row looks * - [getEntryValue] or [getEntryActor] define what the cells display * - [getEntryValue] or [getTotalsActor] define what the totals row displays * @param IT The item type - what defines the row @@ -34,9 +42,9 @@ interface ISortableGridContentProvider { /** When overridden `true`, the entry cells of this column will be equalized to their max height */ val equalizeHeight: Boolean - /** When `true` the column will be sorted descending when the user switches sort to it. */ + /** Default sort direction when a column is first sorted - can be None to disable sorting entirely for this column. */ // Relevant for visuals (simply inverting the comparator would leave the displayed arrow not matching) - val defaultDescending: Boolean + val defaultSort: SortableGrid.SortDirection /** @return whether the column should be rendered */ fun isVisible(gameInfo: GameInfo): Boolean = true @@ -47,8 +55,10 @@ interface ISortableGridContentProvider { */ fun getComparator(): Comparator = compareBy { item: IT -> getEntryValue(item) } - /** Factory for the header cell [Actor] */ - fun getHeaderIcon(iconSize: Float): Actor? + /** Factory for the header cell [Actor] + * @param iconSize Suggestion for icon size passed down from [SortableGrid] constructor, intended to scale the grid header. If the actor is not an icon, treat as height. + */ + fun getHeaderActor(iconSize: Float): Actor? /** A getter for the numeric value to display in a cell */ fun getEntryValue(item: IT): Int @@ -57,7 +67,8 @@ interface ISortableGridContentProvider { * - By default displays the (numeric) result of [getEntryValue]. * - [actionContext] can be used to define `onClick` actions. */ - fun getEntryActor(item: IT, iconSize: Float, actionContext: ACT): Actor? + fun getEntryActor(item: IT, iconSize: Float, actionContext: ACT): Actor? = + getEntryValue(item).toCenteredLabel() /** Factory for totals cell [Actor] * - By default displays the sum over [getEntryValue]. @@ -66,6 +77,21 @@ interface ISortableGridContentProvider { * - On the other hand, a sum may not be meaningful even if the cells are numbers - to leave * the total empty override to return `null`. */ - fun getTotalsActor(items: Iterable): Actor? + fun getTotalsActor(items: Iterable): Actor? = + items.sumOf { getEntryValue(it) }.toCenteredLabel() + companion object { + @JvmStatic + val collator = UncivGame.Current.settings.getCollatorFromLocale() + + @JvmStatic + fun getCircledIcon(path: String, iconSize: Float, circleColor: Color = Color.LIGHT_GRAY) = + ImageGetter.getImage(path) + .apply { color = Color.BLACK } + .surroundWithCircle(iconSize, color = circleColor) + + @JvmStatic + fun Int.toCenteredLabel(): Label = + this.toLabel().apply { setAlignment(Align.center) } + } } diff --git a/core/src/com/unciv/ui/components/widgets/SortableGrid.kt b/core/src/com/unciv/ui/components/widgets/SortableGrid.kt index 0ca03485b5..9516666fa6 100644 --- a/core/src/com/unciv/ui/components/widgets/SortableGrid.kt +++ b/core/src/com/unciv/ui/components/widgets/SortableGrid.kt @@ -5,8 +5,11 @@ import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.Group import com.badlogic.gdx.scenes.scene2d.ui.Cell +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup +import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.utils.Layout import com.badlogic.gdx.utils.Align import com.unciv.ui.components.ISortableGridContentProvider import com.unciv.ui.components.UncivTooltip.Companion.addTooltip @@ -15,6 +18,7 @@ import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.pad import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.input.onClick +import com.unciv.ui.images.IconCircleGroup import com.unciv.ui.screens.basescreen.BaseScreen @@ -30,7 +34,10 @@ import com.unciv.ui.screens.basescreen.BaseScreen class SortableGrid> ( /** Provides the columns to render as [ISortableGridContentProvider] instances */ private val columns: Iterable, - /** Provides the actual "data" as in one object per row that can then be passed to [ISortableGridContentProvider] methods to fetch cell content */ + /** Provides the actual "data" as in one object per row that can then be passed to [ISortableGridContentProvider] methods to fetch cell content. + * + * Note: If your initial [sortState] has [SortDirection.None], then enumeration order of this will determine initial presentation order. + */ private val data: Iterable, /** Passed to [ISortableGridContentProvider.getEntryActor] where it can be used to define `onClick` actions. */ private val actionContext: ACT, @@ -81,10 +88,10 @@ class SortableGrid> ( } private val headerRow = Table(skin) - private val headerIcons = hashMapOf() + private val headerElements = hashMapOf() private val sortSymbols = hashMapOf() - private val details = Table(skin) + val details = Table(skin) // public otherwise can't be used in reified public method private val totalsRow = Table(skin) init { @@ -130,20 +137,33 @@ class SortableGrid> ( sortSymbols[true] = "↓".toLabel() // U+FFEC for (column in columns) { - val group = HeaderGroup(column) - headerIcons[column] = group - headerRow.add(group).size(iconSize).align(column.align) - .fill(column.fillX, false).expand(column.expandX, false) + val element = getHeaderElement(column) + headerElements[column] = element + val cell = headerRow.add(element.outerActor) + element.sizeCell(cell) + cell.align(column.align).fill(column.fillX, false).expand(column.expandX, false) } } + /** Calls [updateHeader] and [updateDetails] but not [updateCallback]. + * + * Clients can call this if some data change affects cell content and sorting. + * Note there is optimization potential here - a mechanism that updates one cell, and resorts only if it is in the currently sorted column + */ + fun update() { + updateHeader() + updateDetails() + } + + /** Update the sort direction icons of the header */ fun updateHeader() { for (column in columns) { val sortDirection = if (sortState.sortedBy == column) sortState.direction else SortDirection.None - headerIcons[column]?.setSortState(sortDirection) + headerElements[column]?.setSortState(sortDirection) } } + /** Rebuild the grid cells to update and/or resort the data */ fun updateDetails() { details.clear() if (data.none()) return @@ -190,64 +210,154 @@ class SortableGrid> ( fun SortDirection.inverted() = when { this == SortDirection.Ascending -> SortDirection.Descending this == SortDirection.Descending -> SortDirection.Ascending - sortBy.defaultDescending -> SortDirection.Descending - else -> SortDirection.Ascending + else -> sortBy.defaultSort } + val direction = if (sortState.sortedBy == sortBy) sortState.direction else SortDirection.None + setSort(sortBy, direction.inverted()) + } - sortState.run { - if (sortedBy == sortBy) { - direction = direction.inverted() - } else { - sortedBy = sortBy - direction = SortDirection.None.inverted() - } - } + fun setSort(sortBy: CT, direction: SortDirection) { + sortState.sortedBy = sortBy + sortState.direction = direction // Rebuild header content to show sort state - updateHeader() - // Sort the table: clear and fill with sorted data - updateDetails() + // And resort the table: clear and fill with sorted data + update() fireCallback() } - // We must be careful - this is an inner class in order to have access to the SortableGrid - // type parameters, but that also means we have access to this@SortableGrid - automatically. - // Any unqualified method calls not implemented in this or a superclass will silently try a - // method offered by Table! Thus all the explicit `this` - to be really safe. - // - // Using Group to overlay an optional sort symbol on top of the icon - we could also - // do HorizontalGroup to have them side by side. Also, note this is not a WidgetGroup - // so all layout details are left to the container - in this case, a Table.Cell - // This will knowingly place the arrow partly outside the Group bounds. - /** Wrap icon and sort symbol for a header cell */ - inner class HeaderGroup(column: CT) : Group() { - private val icon = column.getHeaderIcon(iconSize) - private var sortShown: SortDirection = SortDirection.None + /** Find the first Cell that contains an Actor of type [T] that matches [predicate]. + * @return `null` if no such Actor found */ + inline fun findCell(predicate: (T) -> Boolean): Cell? = + details.cells.asSequence() + .filterIsInstance>() // does not remove cells with null actors, still necessary so the return type is fulfilled + .firstOrNull { it.actor is T && predicate(it.actor) } - init { - this.isTransform = false - this.setSize(iconSize, iconSize) - if (icon != null) { - this.onClick { toggleSort(column) } - icon.setSize(iconSize, iconSize) - icon.center(this) - if (column.headerTip.isNotEmpty()) - icon.addTooltip(column.headerTip, 18f, tipAlign = Align.center, hideIcons = column.headerTipHideIcons) - this.addActor(icon) - } - } + /** Find the first Cell that contains an Actor of type [T] with the given [name][Actor.name]. + * + * Not to be confused with [Group.findActor], which does a recursive search over children, does not filter by the type, and returns the actor. + * @return `null` if no such Actor found */ + inline fun findCell(name: String): Cell? = + findCell { it.name == name } + + + // We must be careful - the implementations of IHeaderElement are inner classes in order to have access + // to the SortableGrid type parameters, but that also means we have access to this@SortableGrid - automatically. + // Any unqualified method calls not implemented in this or a superclass will silently try a + // method offered by Table! Thus make sure any Actor methods run for the contained actors. + + /** Wrap icon, label or other Actor and sort symbol for a header cell. + * + * Not an Actor but a wrapper that contains [outerActor] which goes into the actual header. + * + * Where/how the sort symbol is rendered depends on the type returned by [ISortableGridContentProvider.getHeaderActor]. + * Instantiate through [getHeaderElement] - can be [EmptyHeaderElement], [LayoutHeaderElement] or [IconHeaderElement]. + */ + // Note - not an Actor because Actor is not an interface. Otherwise we *could* build a class that **is** an Actor which can be + // implemented by any Actor subclass but also carry additional fields and methods - via delegation. + interface IHeaderElement { + val outerActor: Actor + val headerActor: Actor? + var sortShown: SortDirection /** Show or remove the sort symbol. - * @param showSort None removes the symbol, Ascending shows an up arrow, Descending a down arrow */ - fun setSortState(showSort: SortDirection) { - if (showSort == sortShown) return - for (symbol in sortSymbols.values) - removeActor(symbol) // Important: Does nothing if the actor is not our child - sortShown = showSort - if (showSort == SortDirection.None) return - val sortSymbol = sortSymbols[showSort == SortDirection.Descending]!! + * @param newSort None removes the symbol, Ascending shows an up arrow, Descending a down arrow */ + fun setSortState(newSort: SortDirection) + + /** Internal: Used by common implementation for [setSortState] */ + fun removeSortSymbol(sortSymbol: Label) + /** Internal: Used by common implementation for [setSortState] */ + fun showSortSymbol(sortSymbol: Label) + + /** Override in case the implementation has specific requirements for the Table.Cell its [outerActor] is hosted in. */ + fun sizeCell(cell: Cell) {} + } + + private fun IHeaderElement.initActivationAndTooltip(column: CT) { + if (column.defaultSort != SortDirection.None) + outerActor.onClick { toggleSort(column) } + if (column.headerTip.isNotEmpty()) + headerActor!!.addTooltip(column.headerTip, 18f, tipAlign = Align.center, hideIcons = column.headerTipHideIcons) + } + + private fun IHeaderElement.setSortStateImpl(newSort: SortDirection) { + if (newSort == sortShown) return + for (symbol in sortSymbols.values) + removeSortSymbol(symbol) // Important: Does nothing if the actor is not our child + sortShown = newSort + if (newSort == SortDirection.None) return + val sortSymbol = sortSymbols[newSort == SortDirection.Descending]!! + showSortSymbol(sortSymbol) + } + + private inner class EmptyHeaderElement : IHeaderElement { + override val outerActor = Actor() + override val headerActor = null + override var sortShown = SortDirection.None + override fun setSortState(newSort: SortDirection) {} + override fun removeSortSymbol(sortSymbol: Label) {} + override fun showSortSymbol(sortSymbol: Label) {} + } + + /** Version of [IHeaderElement] that works fine for Image or IconCircleGroup and **overlays** the sort symbol on its lower right. + * Also used for all non-Layout non-null returns from [ISortableGridContentProvider.getHeaderActor]. + */ + // Note this is not a WidgetGroup and thus does not implement Layout, so all layout details are left to the container + // - in this case, a Table.Cell. This will knowingly place the arrow partly outside the Group bounds. + private inner class IconHeaderElement(column: CT, override val headerActor: Actor) : IHeaderElement { + override val outerActor = Group() + override var sortShown = SortDirection.None + + init { + outerActor.isTransform = false + outerActor.setSize(iconSize, iconSize) + outerActor.addActor(headerActor) + headerActor.setSize(iconSize, iconSize) + headerActor.center(outerActor) + initActivationAndTooltip(column) + } + + override fun sizeCell(cell: Cell) { + cell.size(iconSize) + } + override fun setSortState(newSort: SortDirection) = setSortStateImpl(newSort) + override fun removeSortSymbol(sortSymbol: Label) { + outerActor.removeActor(sortSymbol) + } + override fun showSortSymbol(sortSymbol: Label) { sortSymbol.setPosition(iconSize - 2f, 0f) - addActor(sortSymbol) + outerActor.addActor(sortSymbol) + } + } + + /** Version of [IHeaderElement] for all Layout returns from [ISortableGridContentProvider.getHeaderActor]. + * Draws the sort symbol by rendering [headerActor] and the sort symbol side by side in a HorizontalGroup + */ + private inner class LayoutHeaderElement(column: CT, override val headerActor: Actor) : IHeaderElement { + override val outerActor = HorizontalGroup() + override var sortShown = SortDirection.None + + init { + outerActor.isTransform = false + outerActor.align(column.align) + outerActor.addActor(headerActor) + initActivationAndTooltip(column) + } + override fun setSortState(newSort: SortDirection) = setSortStateImpl(newSort) + override fun removeSortSymbol(sortSymbol: Label) { + outerActor.removeActor(sortSymbol) + } + override fun showSortSymbol(sortSymbol: Label) { + outerActor.addActor(sortSymbol) + } + } + + private fun getHeaderElement(column: CT): IHeaderElement { + return when (val headerActor = column.getHeaderActor(iconSize)) { + null -> EmptyHeaderElement() + is Image, is IconCircleGroup -> IconHeaderElement(column, headerActor) // They're also `is Layout`, but we want the overlaid header version + is Layout -> LayoutHeaderElement(column, headerActor) + else -> IconHeaderElement(column, headerActor) // We haven't got a better implementation for other non-Layout Actors. } } } diff --git a/core/src/com/unciv/ui/screens/overviewscreen/CityOverviewTabColumn.kt b/core/src/com/unciv/ui/screens/overviewscreen/CityOverviewTabColumn.kt index 54c863e60b..8c74717461 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/CityOverviewTabColumn.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/CityOverviewTabColumn.kt @@ -2,20 +2,21 @@ package com.unciv.ui.screens.overviewscreen import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.Actor -import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.utils.Align -import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.city.City import com.unciv.logic.city.CityFlags import com.unciv.models.stats.Stat import com.unciv.models.translations.tr import com.unciv.ui.components.ISortableGridContentProvider +import com.unciv.ui.components.ISortableGridContentProvider.Companion.collator +import com.unciv.ui.components.ISortableGridContentProvider.Companion.getCircledIcon 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.extensions.toTextButton import com.unciv.ui.components.input.onClick +import com.unciv.ui.components.widgets.SortableGrid import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.cityscreen.CityScreen import kotlin.math.roundToInt @@ -34,9 +35,9 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider(collator) { it.name.tr(hideIcons = true) } - override fun getHeaderIcon(iconSize: Float) = + override fun getHeaderActor(iconSize: Float) = ImageGetter.getUnitIcon("Settler") .surroundWithCircle(iconSize) override fun getEntryValue(item: City) = 0 // make sure that `stat!!` in the super isn't used @@ -51,7 +52,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider 3 item.isInResistance() -> 2 @@ -72,7 +73,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider(collator) { it.cityConstructions.currentConstructionFromQueue.tr(hideIcons = true) } - override fun getHeaderIcon(iconSize: Float) = + override fun getHeaderActor(iconSize: Float) = getCircledIcon("OtherIcons/Settings", iconSize) override fun getEntryValue(item: City) = 0 override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen) = @@ -124,10 +125,10 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider(collator) { it.getGarrison()?.name?.tr(hideIcons = true) ?: "" } - override fun getHeaderIcon(iconSize: Float) = + override fun getHeaderActor(iconSize: Float) = getCircledIcon("OtherIcons/Shield", iconSize) override fun getEntryValue(item: City) = if (item.getGarrison() != null) 1 else 0 @@ -166,7 +167,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider): Actor? = - items.sumOf { getEntryValue(it) }.toCenteredLabel() - - companion object { - private val collator = UncivGame.Current.settings.getCollatorFromLocale() - - private fun getCircledIcon(path: String, iconSize: Float, circleColor: Color = Color.LIGHT_GRAY) = - ImageGetter.getImage(path) - .apply { color = Color.BLACK } - .surroundWithCircle(iconSize, color = circleColor) - - private fun Int.toCenteredLabel(): Label = - this.toLabel().apply { setAlignment(Align.center) } - } } diff --git a/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewTab.kt b/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewTab.kt index 3fc0d00562..770b5a905a 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewTab.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/EmpireOverviewTab.kt @@ -16,6 +16,7 @@ abstract class EmpireOverviewTab ( open val persistableData = persistedData ?: EmpireOverviewTabPersistableData() override fun activated(index: Int, caption: String, pager: TabbedPager) { + if (caption.isEmpty()) return overviewScreen.game.settings.lastOverviewPage = caption } diff --git a/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt index 4d29a8f1e0..6eef16a2c4 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt @@ -1,44 +1,16 @@ package com.unciv.ui.screens.overviewscreen -import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.scenes.scene2d.Action import com.badlogic.gdx.scenes.scene2d.Actor -import com.badlogic.gdx.scenes.scene2d.Group import com.badlogic.gdx.scenes.scene2d.actions.Actions import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align -import com.unciv.Constants -import com.unciv.GUI import com.unciv.logic.civilization.Civilization -import com.unciv.logic.map.mapunit.MapUnit -import com.unciv.logic.map.tile.Tile -import com.unciv.models.UnitActionType -import com.unciv.models.UpgradeUnitAction -import com.unciv.models.ruleset.unit.BaseUnit -import com.unciv.ui.components.extensions.addSeparator -import com.unciv.ui.components.extensions.brighten -import com.unciv.ui.components.extensions.center -import com.unciv.ui.components.extensions.darken import com.unciv.ui.components.extensions.equalizeColumns -import com.unciv.ui.components.extensions.surroundWithCircle -import com.unciv.ui.components.extensions.toLabel -import com.unciv.ui.components.extensions.toPrettyString -import com.unciv.ui.components.fonts.Fonts -import com.unciv.ui.components.input.onClick -import com.unciv.ui.components.widgets.ExpanderTab +import com.unciv.ui.components.widgets.SortableGrid import com.unciv.ui.components.widgets.TabbedPager -import com.unciv.ui.components.widgets.UnitGroup import com.unciv.ui.images.IconTextButton -import com.unciv.ui.images.ImageGetter -import com.unciv.ui.popups.UnitUpgradeMenu -import com.unciv.ui.screens.basescreen.BaseScreen -import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen -import com.unciv.ui.screens.pickerscreens.UnitRenamePopup -import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsUpgrade -import kotlin.math.abs -//TODO use SortableGrid /** * Supplies the Unit sub-table for the Empire Overview */ @@ -47,26 +19,14 @@ class UnitOverviewTab( overviewScreen: EmpireOverviewScreen, persistedData: EmpireOverviewTabPersistableData? = null ) : EmpireOverviewTab(viewingPlayer, overviewScreen) { - class UnitTabPersistableData( + class UnitTabPersistableData : EmpireOverviewTabPersistableData(), SortableGrid.ISortState { var scrollY: Float? = null - ) : EmpireOverviewTabPersistableData() { - override fun isEmpty() = scrollY == null + override var sortedBy: UnitOverviewTabColumn = UnitOverviewTabColumn.Name + override var direction = SortableGrid.SortDirection.None + override fun isEmpty() = scrollY == null && sortedBy == UnitOverviewTabColumn.Name && direction != SortableGrid.SortDirection.Descending } override val persistableData = (persistedData as? UnitTabPersistableData) ?: UnitTabPersistableData() - override fun activated(index: Int, caption: String, pager: TabbedPager) { - if (persistableData.scrollY != null) - pager.setPageScrollY(index, persistableData.scrollY!!) - super.activated(index, caption, pager) - } - override fun deactivated(index: Int, caption: String, pager: TabbedPager) { - persistableData.scrollY = pager.getPageScrollY(index) - removeBlinkAction() - } - - private val supplyTableWidth = (overviewScreen.stage.width * 0.25f).coerceAtLeast(240f) - private val unitListTable = Table() // could be `this` instead, extra nesting helps readability a little - private val unitHeaderTable = Table() private val fixedContent = Table() // used for select() @@ -79,262 +39,74 @@ class UnitOverviewTab( blinkActor = null } + //todo The original did its own sort: +/* + val oldIterator = viewingPlayer.units.getCivUnits().sortedWith( + compareBy( + { it.displayName() }, + { !it.due }, + { it.currentMovement <= Constants.minimumMovementEpsilon }, + { abs(it.currentTile.position.x) + abs(it.currentTile.position.y) } + ) + ) +*/ + // We're using getCivUnits enumeration order instead so far. + // - Adding that sort to the data source using Sequence would be inefficient because it is re-enumerated on every resort, and user sorts would mean that initial sort would largely be overridden + // - Materializing the sort result would only waste memory + // - But - isn't getCivUnits() deterministic anyway - controls "Next Unit" order? actually, getCivUnitsStartingAtNextDue would give that, it slices by an internal pointer + + //todo the comments and todo below are copied verbatim from CityOverviewTab - synergies? + private val grid = SortableGrid( + columns = UnitOverviewTabColumn.values().asIterable(), + data = viewingPlayer.units.getCivUnits().asIterable(), + actionContext = this, + sortState = persistableData, + iconSize = 20f, + paddingVert = 5f, + paddingHorz = 8f, + separateHeader = true + ) { header, details, totals -> + // Notes: header.parent is the LinkedScrollPane of TabbedPager. Its linked twin is details.parent.parent.parent however! + // horizontal "slack" if available width > content width is taken up between SortableGrid and CityOverviewTab for the details, + // but not so for the header. We must force the LinkedScrollPane somehow (no? how?) to do so - or the header Table itself. + + equalizeColumns(details, header, totals) + // todo Kludge! Positioning and alignment of the header Table within its parent has quirks when content width < stage width + // This code should likely be included in SortableGrid anyway? + if (header.width < this.width) header.width = this.width + this.validate() + } + override fun getFixedContent() = fixedContent init { - fixedContent.add(getUnitSupplyTable()).align(Align.top).padBottom(10f).row() - fixedContent.add(unitHeaderTable.updateUnitHeaderTable()) + val supplyTableWidth = (overviewScreen.stage.width * 0.25f).coerceAtLeast(240f) + val unitSupplyTable = UnitSupplyTable.create(overviewScreen, this, viewingPlayer, supplyTableWidth) + fixedContent.add(unitSupplyTable).align(Align.top).padBottom(10f).row() + fixedContent.add(grid.getHeader()).grow() top() - add(unitListTable.updateUnitListTable()) - equalizeColumns(unitListTable, unitHeaderTable) + add(grid) } - // Here overloads are simpler than a generic: - private fun Table.addLabeledValue (label: String, value: Int) { - add(label.toLabel()).left() - add(value.toLabel()).right().row() + override fun activated(index: Int, caption: String, pager: TabbedPager) { + if (persistableData.scrollY != null) + pager.setPageScrollY(index, persistableData.scrollY!!) + super.activated(index, caption, pager) } - private fun Table.addLabeledValue (label: String, value: String) { - add(label.toLabel()).left() - add(value.toLabel()).right().row() + override fun deactivated(index: Int, caption: String, pager: TabbedPager) { + persistableData.scrollY = pager.getPageScrollY(index) + removeBlinkAction() } - private fun showWorldScreenAt(position: Vector2, unit: MapUnit?) { - GUI.resetToWorldScreen() - GUI.getMap().setCenterPosition(position, forceSelectUnit = unit) - } - private fun showWorldScreenAt(unit: MapUnit) = showWorldScreenAt(unit.currentTile.position, unit) - private fun showWorldScreenAt(tile: Tile) = showWorldScreenAt(tile.position, null) - - private fun getUnitSupplyTable(): ExpanderTab { - val stats = viewingPlayer.stats - val deficit = stats.getUnitSupplyDeficit() - val icon = if (deficit <= 0) null else Group().apply { - isTransform = false - setSize(36f, 36f) - val image = ImageGetter.getImage("OtherIcons/ExclamationMark") - image.color = Color.FIREBRICK - image.setSize(36f, 36f) - image.center(this) - image.setOrigin(Align.center) - addActor(image) - } - return ExpanderTab( - title = "Unit Supply", - fontSize = Constants.defaultFontSize, - icon = icon, - startsOutOpened = deficit > 0, - defaultPad = 0f, - expanderWidth = supplyTableWidth, - onChange = { - overviewScreen.resizePage(this) - } - ) { - it.defaults().pad(5f).fill(false) - it.background = BaseScreen.skinStrings.getUiBackground( - "OverviewScreen/UnitOverviewTab/UnitSupplyTable", - tintColor = BaseScreen.skinStrings.skinConfig.baseColor.darken(0.6f) - ) - it.addLabeledValue("Base Supply", stats.getBaseUnitSupply()) - it.addLabeledValue("Cities", stats.getUnitSupplyFromCities()) - it.addLabeledValue("Population", stats.getUnitSupplyFromPop()) - it.addSeparator() - it.addLabeledValue("Total Supply", stats.getUnitSupply()) - it.addLabeledValue("In Use", viewingPlayer.units.getCivUnitsSize()) - it.addSeparator() - it.addLabeledValue("Supply Deficit", deficit) - it.addLabeledValue("Production Penalty", "${stats.getUnitSupplyProductionPenalty().toInt()}%") - if (deficit > 0) { - val penaltyLabel = "Increase your supply or reduce the amount of units to remove the production penalty" - .toLabel(Color.FIREBRICK) - penaltyLabel.wrap = true - it.add(penaltyLabel).colspan(2).left() - .width(supplyTableWidth).row() - } - } - } - - private fun Table.updateUnitHeaderTable(): Table { - defaults().pad(5f) - add("Name".toLabel()) - add() // Column: edit-name - add("Action".toLabel()) - add(Fonts.strength.toString().toLabel()) - add(Fonts.rangedStrength.toString().toLabel()) - add(Fonts.movement.toString().toLabel()) - add("Closest city".toLabel()) - add("Promotions".toLabel()) - add("Upgrade".toLabel()) - add("Health".toLabel()) - addSeparator().padBottom(0f) - return this - } - - private fun Table.updateUnitListTable(): Table { - clear() - val game = overviewScreen.game - defaults().pad(5f) - - for (unit in viewingPlayer.units.getCivUnits().sortedWith( - compareBy({ it.displayName() }, - { !it.due }, - { it.currentMovement <= Constants.minimumMovementEpsilon }, - { abs(it.currentTile.position.x) + abs(it.currentTile.position.y) }) - )) { - val baseUnit = unit.baseUnit - - // Unit button column - name, health, fortified, sleeping, embarked are visible here - val button = IconTextButton( - unit.displayName(), - UnitGroup(unit, 20f).apply { if (!unit.isIdle()) color.a = 0.5f }, - fontColor = if (unit.isIdle()) Color.WHITE else Color.LIGHT_GRAY - ) - button.name = getUnitIdentifier(unit) // Marker to find a unit in select() - button.onClick { - showWorldScreenAt(unit) - } - add(button).fillX() - - // Column: edit-name - val editIcon = ImageGetter.getImage("OtherIcons/Pencil").apply { this.color = Color.WHITE }.surroundWithCircle(30f, true, Color.valueOf("000c31")) - editIcon.onClick { - UnitRenamePopup( - screen = overviewScreen, - unit = unit, - actionOnClose = { - overviewScreen.game.replaceCurrentScreen( - EmpireOverviewScreen(viewingPlayer, selection = getUnitIdentifier(unit)) - ) - }) - } - add(editIcon) - - // Column: action - fun getWorkerActionText(unit: MapUnit): String? = when { - // See UnitTurnManager.endTurn, if..workOnImprovement or UnitGroup.getActionImage: similar logic - !unit.cache.hasUniqueToBuildImprovements -> null - unit.currentMovement == 0f -> null - unit.currentTile.improvementInProgress == null -> null - !unit.canBuildImprovement(unit.getTile().getTileImprovementInProgress()!!) -> null - else -> unit.currentTile.improvementInProgress - } - fun getActionText(unit: MapUnit): String? { - val workerText by lazy { getWorkerActionText(unit) } - return when { - unit.action == null -> workerText - unit.isFortified() -> UnitActionType.Fortify.value - unit.isMoving() -> "Moving" - unit.isAutomated() && workerText != null -> "[$workerText] ${Fonts.automate}" - else -> unit.action - } - } - add(getActionText(unit)?.toLabel()) - - // Columns: strength, ranged - if (baseUnit.strength > 0) add(baseUnit.strength.toLabel()) else add() - if (baseUnit.rangedStrength > 0) add(baseUnit.rangedStrength.toLabel()) else add() - add(unit.getMovementString().toLabel()) - - // Closest city column - val closestCity = - unit.getTile().getTilesInDistance(3).firstOrNull { it.isCityCenter() } - val cityColor = if (unit.getTile() == closestCity) Color.FOREST.brighten(0.5f) else Color.WHITE - if (closestCity != null) - add(closestCity.getCity()!!.name.toLabel(cityColor).apply { - onClick { showWorldScreenAt(closestCity) } - }) - else add() - - // Promotions column - val promotionsTable = Table() - updatePromotionsTable(promotionsTable, unit) - promotionsTable.onClick { - if (unit.promotions.canBePromoted() || unit.promotions.promotions.isNotEmpty()) { - game.pushScreen(PromotionPickerScreen(unit) { - updatePromotionsTable(promotionsTable, unit) - }) - } - } - add(promotionsTable) - - // Upgrade column - val upgradeTable = Table() - updateUpgradeTable(upgradeTable, unit) - add(upgradeTable) - - // Numeric health column - there's already a health bar on the button, but...? - if (unit.health < 100) add(unit.health.toLabel()) else add() - row() - } - return this - } - - private fun updateUpgradeTable(table: Table, unit: MapUnit){ - table.clearChildren() - - val unitActions = UnitActionsUpgrade.getUpgradeActionAnywhere(unit) - if (unitActions.none()) table.add() - for (unitAction in unitActions){ - val enable = unitAction.action != null && viewingPlayer.isCurrentPlayer() && - GUI.isAllowedChangeState() - val unitToUpgradeTo = (unitAction as UpgradeUnitAction).unitToUpgradeTo - val selectKey = getUnitIdentifier(unit, unitToUpgradeTo) - val upgradeIcon = ImageGetter.getUnitIcon(unitToUpgradeTo.name, - if (enable) Color.GREEN else Color.GREEN.darken(0.5f)) - upgradeIcon.onClick { - UnitUpgradeMenu(overviewScreen.stage, upgradeIcon, unit, unitAction, enable) { - unitListTable.updateUnitListTable() - select(selectKey) - } - } - table.add(upgradeIcon).size(28f) - } - } - - private fun updatePromotionsTable(table: Table, unit: MapUnit) { - table.clearChildren() - - // getPromotions goes by json order on demand - so this is the same sorting as on UnitTable, - // but not same as on PromotionPickerScreen (which e.g. tries to respect prerequisite proximity) - val promotions = unit.promotions.getPromotions(true) - val showPromoteStar = unit.promotions.canBePromoted() - if (promotions.any()) { - val iconCount = promotions.count() + (if (showPromoteStar) 1 else 0) - val numberOfLines = (iconCount - 1) / 8 + 1 // Int math: -1,/,+1 means divide rounding *up* - val promotionsPerLine = (iconCount - 1) / numberOfLines + 1 - for (linePromotions in promotions.chunked(promotionsPerLine)) { - for (promotion in linePromotions) { - table.add(ImageGetter.getPromotionPortrait(promotion.name)) - } - if (linePromotions.size == promotionsPerLine) table.row() - } - } - - if (!showPromoteStar) return - table.add( - ImageGetter.getImage("OtherIcons/Star").apply { - color = if (GUI.isAllowedChangeState() && unit.currentMovement > 0f && unit.attacksThisTurn == 0) - Color.GOLDENROD - else Color.GOLDENROD.darken(0.25f) - } - ).size(24f).padLeft(8f) - } - - companion object { - fun getUnitIdentifier(unit: MapUnit, unitToUpgradeTo: BaseUnit? = null): String { - val name = unitToUpgradeTo?.name ?: unit.name - return "$name@${unit.getTile().position.toPrettyString()}" - } - } + internal fun update() = grid.update() override fun select(selection: String): Float? { - val cell = unitListTable.cells.asSequence() - .filter { it.actor is IconTextButton && it.actor.name == selection } - .firstOrNull() ?: return null - val button = cell.actor as IconTextButton + val cell = grid.findCell(selection) + ?: return null + val button = cell.actor val scrollY = (0 until cell.row) - .map { unitListTable.getRowHeight(it) }.sum() - - (parent.height - unitListTable.getRowHeight(cell.row)) / 2 + .map { grid.details.getRowHeight(it) }.sum() - + (parent.height - grid.details.getRowHeight(cell.row)) / 2 removeBlinkAction() blinkAction = Actions.repeat(3, Actions.sequence( @@ -345,5 +117,4 @@ class UnitOverviewTab( button.addAction(blinkAction) return scrollY } - } diff --git a/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabColumn.kt b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabColumn.kt new file mode 100644 index 0000000000..cbadf12ca1 --- /dev/null +++ b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabColumn.kt @@ -0,0 +1,143 @@ +package com.unciv.ui.screens.overviewscreen + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.utils.Align +import com.unciv.logic.map.mapunit.MapUnit +import com.unciv.models.translations.tr +import com.unciv.ui.components.ISortableGridContentProvider +import com.unciv.ui.components.ISortableGridContentProvider.Companion.toCenteredLabel +import com.unciv.ui.components.extensions.brighten +import com.unciv.ui.components.extensions.surroundWithCircle +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.fonts.Fonts +import com.unciv.ui.components.input.onClick +import com.unciv.ui.components.widgets.SortableGrid +import com.unciv.ui.components.widgets.UnitGroup +import com.unciv.ui.images.IconTextButton +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.screens.pickerscreens.UnitRenamePopup + +//todo Extending getEntryValue here to have a second String-based "channel" - could go into SortableGrid, possibly by defining a DataType per column??? + +enum class UnitOverviewTabColumn( + private val headerLabel: String? = null, + override val headerTip: String = "", + private val isNumeric: Boolean = false +) : ISortableGridContentProvider { + //region Enum Instances + Name { + override val fillX = true + override fun getEntryString(item: MapUnit) = item.displayName().tr(hideIcons = true) + override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor { + // Unit button column - name, health, fortified, sleeping, embarked are visible here + val button = IconTextButton( + item.displayName(), + UnitGroup(item, 20f).apply { if (!unit.isIdle()) color.a = 0.5f }, + fontColor = if (item.isIdle()) Color.WHITE else Color.LIGHT_GRAY + ) + button.name = getUnitIdentifier(item) // Marker to find a unit in select() + button.onClick { + showWorldScreenAt(item) + } + return button + } + override fun getTotalsActor(items: Iterable) = items.count().toCenteredLabel() + }, + + EditName("") { + override val defaultSort get() = SortableGrid.SortDirection.None + override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor { + val selectKey = getUnitIdentifier(item) + val editIcon = ImageGetter.getImage("OtherIcons/Pencil") + .apply { this.color = Color.WHITE } + .surroundWithCircle(30f, true, Color(0x000c31)) + editIcon.onClick { + UnitRenamePopup(actionContext.overviewScreen, item) { + actionContext.update() + actionContext.overviewScreen.select(EmpireOverviewCategories.Units, selectKey) + } + } + return editIcon + } + }, + + Action { + override fun getEntryString(item: MapUnit): String? = getActionText(item) + }, + + Strength(Fonts.strength.toString(), "Strength", true) { + override val defaultSort get() = SortableGrid.SortDirection.Descending + override fun getEntryValue(item: MapUnit) = item.baseUnit.strength + }, + RangedStrength(Fonts.rangedStrength.toString(), "Ranged strength", true) { + override val defaultSort get() = SortableGrid.SortDirection.Descending + override fun getEntryValue(item: MapUnit) = item.baseUnit.rangedStrength + }, + Movement(Fonts.movement.toString(), "Movement", true) { + override val defaultSort get() = SortableGrid.SortDirection.Descending + override fun getEntryString(item: MapUnit) = item.getMovementString() + override fun getComparator() = compareBy { it.getMaxMovement() }.thenBy { it.currentMovement } + }, + + ClosestCity("Closest city") { + //todo these overrides call a getTilesInDistance(3).firstOrNull loop independently and possibly repeatedly - caching? + override fun getEntryString(item: MapUnit) = getClosestCityTile(item)?.getCity()?.name + + override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor? { + val closestCityTile = getClosestCityTile(item) ?: return null + val cityColor = if (item.getTile() == closestCityTile) Color.FOREST.brighten(0.5f) else Color.WHITE + val label = closestCityTile.getCity()!!.name.toLabel(fontColor = cityColor, alignment = Align.center) + label.onClick { showWorldScreenAt(closestCityTile) } + return label + } + private fun getClosestCityTile(item: MapUnit) = item.getTile() + .getTilesInDistance(3).firstOrNull { it.isCityCenter() } + }, + + Promotions(isNumeric = true) { + override val defaultSort get() = SortableGrid.SortDirection.Descending + override fun getEntryValue(item: MapUnit) = + (if (item.promotions.canBePromoted()) 10000 else 0) + + item.promotions.promotions.size // Not numberOfPromotions - DO count free ones. Or sort by totalXpProduced? + override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab) = getPromotionsTable(item, actionContext) + }, + + Upgrade { + //todo these overrides call UnitActionsUpgrade.getUpgradeActionAnywhere independently and possibly repeatedly - caching? + override fun getEntryString(item: MapUnit) = getUpgradeSortString(item) + override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab) = getUpgradeTable(item, actionContext) + override fun getTotalsActor(items: Iterable) = items.count { getUpgradeSortString(it) != null }.toCenteredLabel() + }, + + Health(isNumeric = true) { + override fun getEntryValue(item: MapUnit) = item.health + override fun getEntryString(item: MapUnit) = if (item.health == 100) null else item.health.toString() + override fun getTotalsActor(items: Iterable) = items.count { it.health < 100 }.toCenteredLabel() + }, + ; + //endregion + + //region Overridden superclass fields + override val align = Align.center + override val fillX = false + override val expandX = false + override val equalizeHeight = false + override val defaultSort get() = SortableGrid.SortDirection.Ascending + //endregion + + open fun getEntryString(item: MapUnit): String? = getEntryValue(item).takeIf { it > 0 }?.toString() + + //region Overridden superclass methods + override fun getHeaderActor(iconSize: Float) = (headerLabel ?: name).toLabel() + override fun getEntryValue(item: MapUnit) = 0 + override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor? = + getEntryString(item)?.toLabel(alignment = Align.center) + override fun getComparator() = if (isNumeric) super.getComparator() + // Sort empty cells to the end by faking a `String.MAX_VALUE` - to do it properly would be a far more verbose Comparator subclass + else compareBy(ISortableGridContentProvider.collator) { getEntryString(it)?.tr(hideIcons = true) ?: "\uD83D\uDE00zzz" } + override fun getTotalsActor(items: Iterable): Actor? = null + //endregion + + companion object : UnitOverviewTabHelpers() +} diff --git a/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabHelpers.kt b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabHelpers.kt new file mode 100644 index 0000000000..267117f2d2 --- /dev/null +++ b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTabHelpers.kt @@ -0,0 +1,151 @@ +package com.unciv.ui.screens.overviewscreen + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.math.Vector2 +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.unciv.GUI +import com.unciv.logic.map.mapunit.MapUnit +import com.unciv.logic.map.tile.Tile +import com.unciv.models.UnitActionType +import com.unciv.models.UpgradeUnitAction +import com.unciv.models.ruleset.unit.BaseUnit +import com.unciv.models.translations.tr +import com.unciv.ui.components.UncivTooltip.Companion.addTooltip +import com.unciv.ui.components.extensions.darken +import com.unciv.ui.components.extensions.toPrettyString +import com.unciv.ui.components.fonts.Fonts +import com.unciv.ui.components.input.onClick +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.UnitUpgradeMenu +import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen +import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsUpgrade + +/** + * Helper library for [UnitOverviewTabColumn] + * + * Note - this will be made into a companion object by simply inheriting it, so do treat it as singleton + */ +open class UnitOverviewTabHelpers { + /** Create an identifier to support selecting a specific unit - or finding it again after a resort or after an upgrade. + * This is for UI only, as there can be no 100% guarantee the find will succeed or be unambiguous. + */ + internal fun getUnitIdentifier(unit: MapUnit, unitToUpgradeTo: BaseUnit? = null): String { + val name = unitToUpgradeTo?.name ?: unit.name + return "$name@${unit.getTile().position.toPrettyString()}" + } + + private fun showWorldScreenAt(position: Vector2, unit: MapUnit?) { + GUI.resetToWorldScreen() + GUI.getMap().setCenterPosition(position, forceSelectUnit = unit) + } + + protected fun showWorldScreenAt(unit: MapUnit) = showWorldScreenAt(unit.currentTile.position, unit) + protected fun showWorldScreenAt(tile: Tile) = showWorldScreenAt(tile.position, null) + + private fun getWorkerActionText(unit: MapUnit): String? = when { + // See UnitTurnManager.endTurn, if..workOnImprovement or UnitGroup.getActionImage: similar logic + !unit.cache.hasUniqueToBuildImprovements -> null + unit.currentMovement == 0f -> null + unit.currentTile.improvementInProgress == null -> null + !unit.canBuildImprovement(unit.getTile().getTileImprovementInProgress()!!) -> null + else -> unit.currentTile.improvementInProgress + } + + protected fun getActionText(unit: MapUnit): String? { + val workerText by lazy { getWorkerActionText(unit) } + return when { + unit.action == null -> workerText + unit.isFortified() -> UnitActionType.Fortify.value + unit.isMoving() -> "Moving" + unit.isAutomated() && workerText != null -> "[$workerText] ${Fonts.automate}" + else -> unit.action + } + } + + protected fun getUpgradeTable(unit: MapUnit, actionContext: UnitOverviewTab): Table? { + val table = Table() + val unitActions = UnitActionsUpgrade.getUpgradeActionAnywhere(unit) + if (unitActions.none()) return null + val canEnable = actionContext.viewingPlayer.isCurrentPlayer() && GUI.isAllowedChangeState() + + for (unitAction in unitActions) { + val enable = canEnable && unitAction.action != null + val unitToUpgradeTo = (unitAction as UpgradeUnitAction).unitToUpgradeTo + val selectKey = getUnitIdentifier(unit, unitToUpgradeTo) + val upgradeIcon = ImageGetter.getUnitIcon(unitToUpgradeTo.name, + if (enable) Color.GREEN else Color.GREEN.darken(0.5f)) + upgradeIcon.onClick { + UnitUpgradeMenu(actionContext.overviewScreen.stage, upgradeIcon, unit, unitAction, enable) { + actionContext.update() + actionContext.overviewScreen.select(EmpireOverviewCategories.Units, selectKey) // actionContext.select skips setting scrollY + } + } + upgradeIcon.addTooltip(unitToUpgradeTo.name, 24f, tipAlign = Align.bottomLeft) + table.add(upgradeIcon).size(28f) + } + return table + } + + protected fun getUpgradeSortString(unit: MapUnit): String? { + val upgrade = UnitActionsUpgrade.getUpgradeActionAnywhere(unit).firstOrNull() + ?: return null + return (upgrade as UpgradeUnitAction).unitToUpgradeTo.name.tr(hideIcons = true) + } + + protected fun getPromotionsTable(unit: MapUnit, actionContext: UnitOverviewTab): Table { + // This was once designed to be redrawn in place without rebuilding the grid. + // That created problems with sorting - and determining when the state would allow minimal updating is complex. + // But the old way also had the mini-bug that PromotionPicker allows unit rename which wasn't reflected on the grid... + // Now it always does rebuild all rows (as simple as actionContext.update instead of updatePromotionsTable). + val promotionsTable = Table() + val canEnable = actionContext.viewingPlayer.isCurrentPlayer() && GUI.isAllowedChangeState() + updatePromotionsTable(promotionsTable, unit, canEnable) + val selectKey = getUnitIdentifier(unit) + + fun onPromotionsTableClick() { + val canPromote = canEnable && unit.promotions.canBePromoted() + if (!canPromote && unit.promotions.promotions.isEmpty()) return + // We can either add a promotion or at least view existing ones. + // PromotionPickerScreen is reponsible for checking viewingPlayer.isCurrentPlayer and isAllowedChangeState **again**. + actionContext.overviewScreen.game.pushScreen( + PromotionPickerScreen(unit) { + // Todo seems the picker does not call this if only the unit rename was used + actionContext.update() + actionContext.overviewScreen.select(EmpireOverviewCategories.Units, selectKey) // actionContext.select skips setting scrollY + } + ) + } + promotionsTable.onClick(::onPromotionsTableClick) + return promotionsTable + } + + private fun updatePromotionsTable(table: Table, unit: MapUnit, canEnable: Boolean) { + table.clearChildren() + + // getPromotions goes by json order on demand - so this is the same sorting as on UnitTable, + // but not same as on PromotionPickerScreen (which e.g. tries to respect prerequisite proximity) + val promotions = unit.promotions.getPromotions(true) + val showPromoteStar = unit.promotions.canBePromoted() + if (promotions.any()) { + val iconCount = promotions.count() + (if (showPromoteStar) 1 else 0) + val numberOfLines = (iconCount - 1) / 8 + 1 // Int math: -1,/,+1 means divide rounding *up* + val promotionsPerLine = (iconCount - 1) / numberOfLines + 1 + for (linePromotions in promotions.chunked(promotionsPerLine)) { + for (promotion in linePromotions) { + table.add(ImageGetter.getPromotionPortrait(promotion.name)) + } + if (linePromotions.size == promotionsPerLine) table.row() + } + } + + if (!showPromoteStar) return + table.add( + ImageGetter.getImage("OtherIcons/Star").apply { + color = if (canEnable && unit.currentMovement > 0f && unit.attacksThisTurn == 0) + Color.GOLDENROD + else Color.GOLDENROD.darken(0.25f) + } + ).size(24f).padLeft(8f) + } +} diff --git a/core/src/com/unciv/ui/screens/overviewscreen/UnitSupplyTable.kt b/core/src/com/unciv/ui/screens/overviewscreen/UnitSupplyTable.kt new file mode 100644 index 0000000000..1cf103ce65 --- /dev/null +++ b/core/src/com/unciv/ui/screens/overviewscreen/UnitSupplyTable.kt @@ -0,0 +1,81 @@ +package com.unciv.ui.screens.overviewscreen + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Group +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.unciv.Constants +import com.unciv.logic.civilization.Civilization +import com.unciv.ui.components.extensions.addSeparator +import com.unciv.ui.components.extensions.center +import com.unciv.ui.components.extensions.darken +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.widgets.ExpanderTab +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.screens.basescreen.BaseScreen + +// This is a static factory to avoid making ExpanderTab open. UnitSupplyTable object used purely as namespace. +internal object UnitSupplyTable { + fun create( + overviewScreen: EmpireOverviewScreen, + unitOverviewTab: UnitOverviewTab, + viewingPlayer: Civilization, + supplyTableWidth: Float + ): ExpanderTab { + val stats = viewingPlayer.stats + val deficit = stats.getUnitSupplyDeficit() + val icon = if (deficit <= 0) null else Group().apply { + isTransform = false + setSize(36f, 36f) + val image = ImageGetter.getImage("OtherIcons/ExclamationMark") + image.color = Color.FIREBRICK + image.setSize(36f, 36f) + image.center(this) + image.setOrigin(Align.center) + addActor(image) + } + return ExpanderTab( + title = "Unit Supply", + fontSize = Constants.defaultFontSize, + icon = icon, + startsOutOpened = deficit > 0, + defaultPad = 0f, + expanderWidth = supplyTableWidth, + onChange = { + overviewScreen.resizePage(unitOverviewTab) + } + ) { + it.defaults().pad(5f).fill(false) + it.background = BaseScreen.skinStrings.getUiBackground( + "OverviewScreen/UnitOverviewTab/UnitSupplyTable", + tintColor = BaseScreen.skinStrings.skinConfig.baseColor.darken(0.6f) + ) + it.addLabeledValue("Base Supply", stats.getBaseUnitSupply()) + it.addLabeledValue("Cities", stats.getUnitSupplyFromCities()) + it.addLabeledValue("Population", stats.getUnitSupplyFromPop()) + it.addSeparator() + it.addLabeledValue("Total Supply", stats.getUnitSupply()) + it.addLabeledValue("In Use", viewingPlayer.units.getCivUnitsSize()) + it.addSeparator() + it.addLabeledValue("Supply Deficit", deficit) + it.addLabeledValue("Production Penalty", "${stats.getUnitSupplyProductionPenalty().toInt()}%") + if (deficit > 0) { + val penaltyLabel = "Increase your supply or reduce the amount of units to remove the production penalty" + .toLabel(Color.FIREBRICK) + penaltyLabel.wrap = true + it.add(penaltyLabel).colspan(2).left() + .width(supplyTableWidth).row() + } + } + } + + // Here overloads are simpler than a generic: + private fun Table.addLabeledValue (label: String, value: Int) { + add(label.toLabel()).left() + add(value.toLabel()).right().row() + } + private fun Table.addLabeledValue (label: String, value: String) { + add(label.toLabel()).left() + add(value.toLabel()).right().row() + } +} diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt b/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt index 7e1f64e06b..6fa8a03b86 100644 --- a/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt +++ b/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt @@ -16,6 +16,7 @@ import com.unciv.models.translations.tr import com.unciv.ui.audio.SoundPlayer import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.KeyboardBinding import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.components.input.onActivation @@ -26,11 +27,20 @@ import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.RecreateOnResize import kotlin.math.abs -class PromotionPickerScreen( +class PromotionPickerScreen private constructor( val unit: MapUnit, - private val closeOnPick: Boolean = true, - private val onChange: (() -> Unit)? = null + private val closeOnPick: Boolean, + private val originalName: String?, + private val onChange: (() -> Unit)? ) : PickerScreen(), RecreateOnResize { + /** Show promotions organized by depencencies, allow picking new ones, allow unit rename + * @param unit The MapUnit to work with + * @param closeOnPick Should picking a new promotion close the screen? + * @param onChange Optional callback called when a promotion is picked or during close if the name was changed + */ + constructor(unit: MapUnit, closeOnPick: Boolean = true, onChange: (() -> Unit)? = null) + : this(unit, closeOnPick, unit.instanceName, onChange) + // Style stuff private val colors = skin[PromotionScreenColors::class.java] private val promotedLabelStyle = Label.LabelStyle(skin[Label.LabelStyle::class.java]).apply { @@ -56,7 +66,12 @@ class PromotionPickerScreen( init { - setDefaultCloseAction() + closeButton.onActivation { + if (unit.instanceName != originalName) + onChange?.invoke() + game.popScreen() + } + closeButton.keyShortcuts.add(KeyCharAndCode.BACK) if (canPromoteNow) { rightSideButton.setText("Pick promotion".tr()) @@ -339,7 +354,7 @@ class PromotionPickerScreen( override fun recreate() = recreate(closeOnPick) fun recreate(closeOnPick: Boolean): BaseScreen { - val newScreen = PromotionPickerScreen(unit, closeOnPick, onChange) + val newScreen = PromotionPickerScreen(unit, closeOnPick, originalName, onChange) newScreen.setScrollY(scrollPane.scrollY) return newScreen }