Generic Widget/Provider framework for sortable grids (#8962)

* City Overview reorg - adding a Column should get easier

* City Overview reorg - patch

* City Overview reorg - SortableGrid Widget

* SortableGrid Widget - cleanup

* Generalize equalizeColumns

* SortableGrid framework - cleaner v2

* Revert file rename to facilitate merge

* Post-merge missed change

* SortableGrid merge fix

* Resolve wildcard import

* Post-merge fix: showOneTimeNotification

* Post-merge fixes

* Post-merge cleanup

* More Post-merge cleanup

* Fix sort (bug symptom: dependence on column click order)

* Tooltip update to "fix" icons if hideIcons=false

* Allow hideIcons control for grid header Tooltip

* Lint String.tr() Kdoc

* Move getComparator() default implementation to interface

* Nicer getComparator() implementations, better sorting for WLTK column

* Fix "Tooltip update to "fix" icons" reverting tooltip color

* Suppress detekt false positives

* Fix merge error
This commit is contained in:
SomeTroglodyte
2023-09-13 18:42:22 +02:00
committed by GitHub
parent fe18a22cf7
commit e59426fb03
19 changed files with 635 additions and 322 deletions

View File

@ -29,11 +29,10 @@ jobs:
id: setup_detekt
uses: peter-murray/setup-detekt@v2
with:
detekt_version: '1.23.0-RC3'
detekt_version: '1.23.1'
- name: Detekt errors
run: detekt-cli --parallel --config detekt/config/detekt-errors.yml
- name: Detekt warnings
run: detekt-cli --parallel --config detekt/config/detekt-warnings.yml

View File

@ -283,7 +283,7 @@ object TranslationActiveModsCache {
}
}
/**
/**
* This function does the actual translation work,
* using an instance of [Translations] stored in UncivGame.Current
*
@ -293,12 +293,13 @@ object TranslationActiveModsCache {
* sentences - contains at least one '{'
* - phrases between curly braces are translated individually
* Additionally, they may contain conditionals between '<' and '>'
* @param hideIcons disables auto-inserting icons for ruleset objects (but not Stats)
* @return The translated string
* defaults to the input string if no translation is available,
* but with placeholder or sentence brackets removed.
*/
fun String.tr(hideIcons:Boolean = false): String {
val language:String = UncivGame.Current.settings.language
fun String.tr(hideIcons: Boolean = false): String {
val language: String = UncivGame.Current.settings.language
// '<' and '>' checks for quick 'no' answer, regex to ensure that no one accidentally put '><' and ruined things
if (contains('<') && contains('>') && pointyBraceRegex.containsMatchIn(this)) { // Conditionals!

View File

@ -117,7 +117,7 @@ class ColorMarkupLabel private constructor(
return translated.replace('«', '[').replace('»', ']')
}
private fun prepareText(text: String, textColor: Color, symbolColor: Color): String {
fun prepareText(text: String, textColor: Color, symbolColor: Color): String {
val translated = text.tr()
if ((textColor == Color.WHITE && symbolColor == Color.WHITE) || translated.isBlank())
return translated

View File

@ -0,0 +1,71 @@
package com.unciv.ui.components
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.unciv.logic.GameInfo
/**
* 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
* - [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
* @param ACT Action context type - The Type of any object you need passed to [getEntryActor] for potential OnClick calls
*/
interface ISortableGridContentProvider<IT, ACT> {
/** tooltip for the column header, typically overridden to default to enum name, will be auto-translated */
val headerTip: String
/** Passed to addTooltip(hideIcons) - override to true to prevent autotranslation from inserting icons */
val headerTipHideIcons get() = false
/** [Cell.align] - used on header, entry and total cells */
val align: Int
/** [Cell.fillX] - used on header, entry and total cells */
val fillX: Boolean
/** [Cell.expandX] - used on header, entry and total cells */
val expandX: Boolean
/** 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. */
// Relevant for visuals (simply inverting the comparator would leave the displayed arrow not matching)
val defaultDescending: Boolean
/** @return whether the column should be rendered */
fun isVisible(gameInfo: GameInfo): Boolean = true
/** [Comparator] Factory used for sorting.
* - The default will sort by [getEntryValue] ascending.
* @return positive to sort second lambda argument before first lambda argument
*/
fun getComparator(): Comparator<IT> = compareBy { item: IT -> getEntryValue(item) }
/** Factory for the header cell [Actor] */
fun getHeaderIcon(iconSize: Float): Actor?
/** A getter for the numeric value to display in a cell */
fun getEntryValue(item: IT): Int
/** Factory for entry cell [Actor]
* - 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?
/** Factory for totals cell [Actor]
* - By default displays the sum over [getEntryValue].
* - Note a count may be meaningful even if entry cells display something other than a number,
* In that case _not_ overriding this and supply a meaningful [getEntryValue] may be easier.
* - 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<IT>): Actor?
}

View File

@ -0,0 +1,242 @@
@file:Suppress("MemberVisibilityCanBePrivate") // A generic Widget has public members not necessarily used elsewhere
package com.unciv.ui.components
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.Label
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.addSeparator
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.screens.basescreen.BaseScreen
/**
* A generic sortable grid Widget
*
* Note this only remembers one sort criterion. Sorts like compareBy(type).thenBy(name) aren't supported.
*
* @param IT Type of the data objects that provide info per row
* @param ACT Type for [actionContext], Anything allowed, specific meaning defined only by ISortableGridContentProvider subclass
* @param CT Type of the columns
*/
class SortableGrid<IT, ACT, CT: ISortableGridContentProvider<IT, ACT>> (
/** Provides the columns to render as [ISortableGridContentProvider] instances */
private val columns: Iterable<CT>,
/** Provides the actual "data" as in one object per row that can then be passed to [ISortableGridContentProvider] methods to fetch cell content */
private val data: Iterable<IT>,
/** Passed to [ISortableGridContentProvider.getEntryActor] where it can be used to define `onClick` actions. */
private val actionContext: ACT,
/** Sorting state will be kept here - provide your own e.g. if you want to persist it */
private val sortState: ISortState<CT> = SortState(columns.first()),
/** Size for header icons - if you set this too low, there is a chance that the tables will be misaligned */
private val iconSize: Float = 50f,
/** vertical padding for all Cells */
paddingVert: Float = 5f,
/** horizontal padding for all Cells */
paddingHorz: Float = 8f,
/** When `true`, the header row isn't part of the widget but delivered through [getHeader] */
private val separateHeader: Boolean = false,
/** Called after every update - during init and re-sort */
private val updateCallback: ((header: Table, details: Table, totals: Table) -> Unit)? = null
) : Table(BaseScreen.skin) {
/** The direction a column may be sorted in */
// None is the Natural order of underlying data - only available before using any sort-click
enum class SortDirection { None, Ascending, Descending }
/** Defines what is needed to remember the sorting state of the grid. */
// Abstract to allow easier implementation outside this class
// Note this does not automatically enforce this CT to be te same as SortableGrid's CT - not here.
// The _client_ will get the compilation errors when passing a custom SortState with a mismatch in CT.
interface ISortState<CT> {
/** Stores the column this grid is currently sorted by */
var sortedBy: CT
/** Stores the direction column [sortedBy] is sorted in */
var direction: SortDirection
}
/** Default implementation used as default for the [sortState] parameter
* - unused if the client provides that parameter */
private class SortState<CT>(default: CT) : ISortState<CT> {
override var sortedBy: CT = default
override var direction: SortDirection = SortDirection.None
}
/** Provides the header row separately if and only if [separateHeader] is true,
* e.g. to allow scrolling the content but leave the header fixed
* (which will need some column width equalization method).
* @see com.unciv.ui.components.extensions.equalizeColumns
*/
fun getHeader(): Table {
if (!separateHeader)
throw IllegalStateException("You can't call SortableGrid.getHeader unless you override separateHeader to true")
return headerRow
}
private val headerRow = Table(skin)
private val headerIcons = hashMapOf<CT, HeaderGroup>()
private val sortSymbols = hashMapOf<Boolean, Label>()
private val details = Table(skin)
private val totalsRow = Table(skin)
init {
headerRow.defaults().pad(paddingVert, paddingHorz).minWidth(iconSize)
details.defaults().pad(paddingVert, paddingHorz).minWidth(iconSize)
totalsRow.defaults().pad(paddingVert, paddingHorz).minWidth(iconSize)
initHeader()
updateHeader()
updateDetails()
initTotals()
fireCallback()
top()
if (!separateHeader) {
add(headerRow).row()
addSeparator(Color.GRAY).pad(paddingVert, 0f)
}
add(details).row()
addSeparator(Color.GRAY).pad(paddingVert, 0f)
add(totalsRow)
}
private fun fireCallback() {
if (updateCallback == null) return
headerRow.pack()
details.pack()
totalsRow.pack()
updateCallback.invoke(headerRow, details, totalsRow)
}
private fun initHeader() {
sortSymbols[false] = "".toLabel()
sortSymbols[true] = "".toLabel()
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)
}
}
fun updateHeader() {
for (column in columns) {
val sortDirection = if (sortState.sortedBy == column) sortState.direction else SortDirection.None
headerIcons[column]?.setSortState(sortDirection)
}
}
fun updateDetails() {
details.clear()
if (data.none()) return
val comparator = sortState.sortedBy.getComparator()
val sortedData = when(sortState.direction) {
SortDirection.None -> data.asSequence()
SortDirection.Ascending -> data.asSequence().sortedWith(comparator)
SortDirection.Descending -> data.asSequence().sortedWith(comparator.reversed())
}
val cellsToEqualize = mutableListOf<Cell<Actor>>()
for (item in sortedData) {
for (column in columns) {
val actor = column.getEntryActor(item, iconSize, actionContext)
if (actor == null) {
details.add()
continue
}
val cell = details.add(actor).align(column.align)
.fill(column.fillX, false).expand(column.expandX, false)
if (column.equalizeHeight) cellsToEqualize.add(cell)
}
details.row()
}
// row heights may diverge - fix it by setting minHeight to
// largest actual height (of the cells marked `equalizeHeight`)
if (cellsToEqualize.isNotEmpty()) {
val largestLabelHeight = cellsToEqualize.maxByOrNull { it.prefHeight }!!.prefHeight
for (cell in cellsToEqualize) cell.minHeight(largestLabelHeight)
}
}
private fun initTotals() {
for (column in columns) {
totalsRow.add(column.getTotalsActor(data)).align(column.align)
.fill(column.fillX, false).expand(column.expandX, false)
}
}
private fun toggleSort(sortBy: CT) {
// Could be a SortDirection method, but it's single use here
fun SortDirection.inverted() = when {
this == SortDirection.Ascending -> SortDirection.Descending
this == SortDirection.Descending -> SortDirection.Ascending
sortBy.defaultDescending -> SortDirection.Descending
else -> SortDirection.Ascending
}
sortState.run {
if (sortedBy == sortBy) {
direction = direction.inverted()
} else {
sortedBy = sortBy
direction = SortDirection.None.inverted()
}
}
// Rebuild header content to show sort state
updateHeader()
// Sort the table: clear and fill with sorted data
updateDetails()
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
/** 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
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)
}
}
/** 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]!!
sortSymbol.setPosition(iconSize - 2f, 0f)
addActor(sortSymbol)
}
}
}

View File

@ -111,41 +111,7 @@ open class TabbedPager(
/** @return Optional second content [Actor], will be placed outside the tab's main [ScrollPane] between header and `content`. Scrolls horizontally only. */
fun getFixedContent(): Actor? = null
/** Sets first row cell's minWidth to the max of the widths of that column over all given tables
*
* Notes:
* - This aligns columns only if the tables are arranged vertically with equal X coordinates.
* - first table determines columns processed, all others must have at least the same column count.
* - Tables are left as needsLayout==true, so while equal width is ensured, you may have to pack if you want to see the value before this is rendered.
*/
fun equalizeColumns(vararg tables: Table) {
for (table in tables)
table.packIfNeeded()
val columns = tables.first().columns
check(tables.all { it.columns >= columns }) {
"IPageExtensions.equalizeColumns needs all tables to have at least the same number of columns as the first one"
}
val widths = (0 until columns)
.mapTo(ArrayList(columns)) { column ->
tables.maxOf { it.getColumnWidth(column) }
}
for (table in tables) {
for (column in 0 until columns)
table.cells[column].run {
if (actor == null)
// Empty cells ignore minWidth, so just doing Table.add() for an empty cell in the top row will break this. Fix!
setActor<Label>("".toLabel())
else if (Align.isCenterHorizontal(align)) (actor as? Label)?.run {
// minWidth acts like fillX, so Labels will fill and then left-align by default. Fix!
if (!Align.isCenterHorizontal(labelAlign))
setAlignment(Align.center)
}
minWidth(widths[column] - padLeft - padRight)
}
table.invalidate()
}
}
// fun equalizeColumns(vararg tables: Table) - moved to com.unciv.ui.components.extensions (Scene2dExtensions)
}
//endregion

View File

@ -224,6 +224,7 @@ class UncivTooltip <T: Actor>(
* @param targetAlign Point on the [target] widget to align the Tooltip to
* @param tipAlign Point on the Tooltip to align with the given point on the [target]
* @param hideIcons Do not automatically add ruleset object icons during translation
* @param dynamicTextProvider If specified, the tooltip calls this every time it is about to be shown to get refreshed text - will be translated. Used e.g. by addTooltip(KeyboardBinding).
*/
fun Actor.addTooltip(
text: String,
@ -241,7 +242,9 @@ class UncivTooltip <T: Actor>(
if (!(always || GUI.keyboardAvailable) || text.isEmpty()) return
val label = text.toLabel(BaseScreen.skinStrings.skinConfig.baseColor, 38, hideIcons = hideIcons)
val labelColor = BaseScreen.skinStrings.skinConfig.baseColor
val label = if (hideIcons) text.toLabel(labelColor, fontSize = 38, hideIcons = true)
else ColorMarkupLabel(text, labelColor, fontSize = 38)
label.setAlignment(Align.center)
val background = BaseScreen.skinStrings.getUiBackground("General/Tooltip", BaseScreen.skinStrings.roundedEdgeRectangleShape, Color.LIGHT_GRAY)
@ -272,7 +275,14 @@ class UncivTooltip <T: Actor>(
val contentRefresher: (() -> Vector2)? = if (dynamicTextProvider == null) null else { {
val newText = dynamicTextProvider()
label.setText(newText)
if (hideIcons)
label.setText(newText.tr())
else
// Note: This is a kludge. `setText` alone would revert the text color since
// ColorMarkupLabel doesn't use Actor.color but markup only. The proper way -
// let ColorMarkupLabel override setText and manage - is much more effort.
// Note this also translates, so for consistency the normal branch above does the same.
label.setText(ColorMarkupLabel.prepareText(newText, labelColor, Color.WHITE))
scaleContainerAndGetSize(newText)
} }

View File

@ -364,3 +364,41 @@ object GdxKeyCodeFixes {
fun Input.areSecretKeysPressed() = isKeyPressed(Input.Keys.SHIFT_RIGHT) &&
(isKeyPressed(Input.Keys.CONTROL_RIGHT) || isKeyPressed(Input.Keys.ALT_RIGHT))
/** Sets first row cell's minWidth to the max of the widths of that column over all given tables
*
* Notes:
* - This aligns columns only if the tables are arranged vertically with equal X coordinates.
* - first table determines columns processed, all others must have at least the same column count.
* - Tables are left as needsLayout==true, so while equal width is ensured, you may have to pack if you want to see the value before this is rendered.
* - Note: The receiver <Group> isn't actually needed except to make sure the arguments are descendants.
*/
fun equalizeColumns(vararg tables: Table) {
for (table in tables) {
table.packIfNeeded()
}
val columns = tables.first().columns
check(tables.all { it.columns >= columns }) {
"IPageExtensions.equalizeColumns needs all tables to have at least the same number of columns as the first one"
}
val widths = (0 until columns)
.mapTo(ArrayList(columns)) { column ->
tables.maxOf { it.getColumnWidth(column) }
}
for (table in tables) {
for (column in 0 until columns)
table.cells[column].run {
if (actor == null)
// Empty cells ignore minWidth, so just doing Table.add() for an empty cell in the top row will break this. Fix!
setActor<Label>("".toLabel())
else if (Align.isCenterHorizontal(align)) (actor as? Label)?.run {
// minWidth acts like fillX, so Labels will fill and then left-align by default. Fix!
if (!Align.isCenterHorizontal(labelAlign))
setAlignment(Align.center)
}
minWidth(widths[column] - padLeft - padRight)
}
table.invalidate()
}
}

View File

@ -1,30 +1,9 @@
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.Group
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.city.City
import com.unciv.logic.city.CityFlags
import com.unciv.logic.civilization.Civilization
import com.unciv.models.stats.Stat
import com.unciv.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.pad
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.images.ImageGetter
import com.unciv.ui.screens.cityscreen.CityScreen
import kotlin.math.roundToInt
import com.unciv.ui.components.SortableGrid
import com.unciv.ui.components.extensions.equalizeColumns
class CityOverviewTab(
viewingPlayer: Civilization,
@ -32,249 +11,32 @@ class CityOverviewTab(
persistedData: EmpireOverviewTabPersistableData? = null
) : EmpireOverviewTab(viewingPlayer, overviewScreen) {
class CityTabPersistableData(
var sortedBy: String = CITY,
var descending: Boolean = false
) : EmpireOverviewTabPersistableData() {
override fun isEmpty() = sortedBy == CITY
override var sortedBy: CityOverviewTabColumn = CityOverviewTabColumn.CityColumn,
) : EmpireOverviewTabPersistableData(), SortableGrid.ISortState<CityOverviewTabColumn> {
override fun isEmpty() = sortedBy == CityOverviewTabColumn.CityColumn
override var direction = SortableGrid.SortDirection.None
}
override val persistableData = (persistedData as? CityTabPersistableData) ?: CityTabPersistableData()
companion object {
const val iconSize = 50f //if you set this too low, there is a chance that the tables will be misaligned
const val paddingVert = 5f // vertical padding
const val paddingHorz = 8f // horizontal padding
private const val CITY = "City"
private const val WLTK = "WLTK"
private const val CONSTRUCTION = "Construction"
private const val GARRISON = "Garrison"
private val alphabeticColumns = listOf(CITY, CONSTRUCTION, WLTK, GARRISON)
private val citySortIcon = ImageGetter.getUnitIcon("Settler")
.surroundWithCircle(iconSize)
.apply { addTooltip("Name", 18f, tipAlign = Align.center) }
private val wltkSortIcon = ImageGetter.getImage("OtherIcons/WLTK 2")
.apply { color = Color.BLACK }
.surroundWithCircle(iconSize, color = Color.TAN)
.apply { addTooltip("We Love The King Day", 18f, tipAlign = Align.center) }
private val constructionSortIcon = ImageGetter.getImage("OtherIcons/Settings")
.apply { color = Color.BLACK }
.surroundWithCircle(iconSize, color = Color.LIGHT_GRAY)
.apply { addTooltip("Current construction", 18f, tipAlign = Align.center) }
private val garrisonSortIcon = ImageGetter.getImage("OtherIcons/Shield")
.apply { color = Color.BLACK }
.surroundWithCircle(iconSize, color = Color.LIGHT_GRAY)
.apply { addTooltip("Garrisoned by unit", 18f, tipAlign = Align.center) }
// Readability helpers
private fun String.isStat() = Stat.isStat(this)
private fun City.getStat(stat: Stat) =
if (stat == Stat.Happiness)
cityStats.happinessList.values.sum().roundToInt()
else cityStats.currentCityStats[stat].roundToInt()
private fun Int.toCenteredLabel(): Label =
this.toLabel().apply { setAlignment(Align.center) }
}
private val columnsNames = arrayListOf("Population", "Food", "Gold", "Science", "Production", "Culture", "Happiness")
.apply { if (gameInfo.isReligionEnabled()) add("Faith") }
private val headerTable = Table(skin)
private val detailsTable = Table(skin)
private val totalTable = Table(skin)
private val collator = UncivGame.Current.settings.getCollatorFromLocale()
override fun getFixedContent() = Table().apply {
add("Cities".toLabel(fontSize = Constants.headingFontSize)).padTop(10f).row()
add(headerTable).padBottom(paddingVert).row()
addSeparator(Color.GRAY)
private val grid = SortableGrid(
columns = CityOverviewTabColumn.values().asIterable(),
data = viewingPlayer.cities,
actionContext = overviewScreen,
sortState = persistableData,
iconSize = 50f, //if you set this too low, there is a chance that the tables will be misaligned
paddingVert = 5f,
paddingHorz = 8f,
separateHeader = false
) {
header, details, totals ->
this.name
equalizeColumns(details, header, totals)
this.layout()
}
init {
headerTable.defaults().pad(paddingVert, paddingHorz).minWidth(iconSize)
detailsTable.defaults().pad(paddingVert, paddingHorz).minWidth(iconSize)
totalTable.defaults().pad(paddingVert, paddingHorz).minWidth(iconSize)
updateTotal()
update()
top()
add(detailsTable).row()
addSeparator(Color.GRAY).pad(paddingVert, 0f)
add(totalTable)
}
private fun toggleSort(sortBy: String) {
if (sortBy == persistableData.sortedBy) {
persistableData.descending = !persistableData.descending
} else {
persistableData.sortedBy = sortBy
persistableData.descending = sortBy !in alphabeticColumns // Start numeric columns descending
}
}
private fun getComparator() = Comparator { city2: City, city1: City ->
when(persistableData.sortedBy) {
CITY -> collator.compare(city2.name.tr(hideIcons = true), city1.name.tr(hideIcons = true))
CONSTRUCTION -> collator.compare(
city2.cityConstructions.currentConstructionFromQueue.tr(hideIcons = true),
city1.cityConstructions.currentConstructionFromQueue.tr(hideIcons = true))
"Population" -> city2.population.population - city1.population.population
WLTK -> city2.isWeLoveTheKingDayActive().compareTo(city1.isWeLoveTheKingDayActive())
GARRISON -> collator.compare(
city2.getGarrison()?.name?.tr(hideIcons = true) ?: "",
city1.getGarrison()?.name?.tr(hideIcons = true) ?: "",
)
else -> {
val stat = Stat.safeValueOf(persistableData.sortedBy)!!
city2.getStat(stat) - city1.getStat(stat)
}
}
}
private fun getSortSymbol() = if(persistableData.descending) "" else ""
private fun update() {
updateHeader()
updateCities()
equalizeColumns(detailsTable, headerTable, totalTable)
layout()
}
private fun updateHeader() {
fun sortOnClick(sortBy: String) {
toggleSort(sortBy)
// sort the table: clear and fill with sorted data
update()
}
fun addSortIcon(iconName: String, iconParam: Actor? = null): Cell<Group> {
val image = iconParam ?: ImageGetter.getStatIcon(iconName)
val icon = Group().apply {
isTransform = false
setSize(iconSize, iconSize)
image.setSize(iconSize, iconSize)
image.center(this)
image.setOrigin(Align.center)
onClick { sortOnClick(iconName) }
}
if (iconName == persistableData.sortedBy) {
val label = getSortSymbol().toLabel()
label.setOrigin(Align.bottomRight)
label.setPosition(iconSize - 2f, 0f)
icon.addActor(label)
}
icon.addActor(image)
return headerTable.add(icon).size(iconSize)
}
headerTable.clear()
addSortIcon(CITY, citySortIcon).left()
headerTable.add() // construction _icon_ column
addSortIcon(CONSTRUCTION, constructionSortIcon).left()
for (name in columnsNames) {
addSortIcon(name)
}
addSortIcon(WLTK, wltkSortIcon)
addSortIcon(GARRISON, garrisonSortIcon)
headerTable.pack()
}
private fun updateCities() {
detailsTable.clear()
if (viewingPlayer.cities.isEmpty()) return
val sorter = getComparator()
var cityList = viewingPlayer.cities.sortedWith(sorter)
if (persistableData.descending)
cityList = cityList.reversed()
val constructionCells: MutableList<Cell<Label>> = mutableListOf()
for (city in cityList) {
val button = city.name.toTextButton(hideIcons = true)
button.onClick {
overviewScreen.game.pushScreen(CityScreen(city))
}
detailsTable.add(button).left().fillX()
val construction = city.cityConstructions.currentConstructionFromQueue
if (construction.isNotEmpty()) {
detailsTable.add(ImageGetter.getConstructionPortrait(construction, iconSize *0.8f)).padRight(
paddingHorz
)
} else {
detailsTable.add()
}
val cell = detailsTable.add(city.cityConstructions.getCityProductionTextForCityButton().toLabel()).left().expandX()
constructionCells.add(cell)
detailsTable.add(city.population.population.toCenteredLabel())
for (column in columnsNames) {
val stat = Stat.safeValueOf(column) ?: continue
detailsTable.add(city.getStat(stat).toCenteredLabel())
}
when {
city.isWeLoveTheKingDayActive() -> {
val image = ImageGetter.getImage("OtherIcons/WLTK 1").surroundWithCircle(
iconSize, color = Color.CLEAR)
image.addTooltip("[${city.getFlag(CityFlags.WeLoveTheKing)}] turns", 18f, tipAlign = Align.topLeft)
detailsTable.add(image)
}
city.demandedResource.isNotEmpty() -> {
val image = ImageGetter.getResourcePortrait(city.demandedResource, iconSize *0.7f).apply {
addTooltip("Demanding [${city.demandedResource}]", 18f, tipAlign = Align.topLeft)
onClick { showOneTimeNotification(
gameInfo.getExploredResourcesNotification(viewingPlayer, city.demandedResource)
) }
}
detailsTable.add(image)
}
else -> detailsTable.add()
}
val garrisonUnit = city.getGarrison()
if (garrisonUnit == null) {
detailsTable.add()
} else {
val garrisonUnitName = garrisonUnit.displayName()
val garrisonUnitIcon = ImageGetter.getConstructionPortrait(garrisonUnit.baseUnit.getIconName(), iconSize * 0.7f)
garrisonUnitIcon.addTooltip(garrisonUnitName, 18f, tipAlign = Align.topLeft)
garrisonUnitIcon.onClick {
overviewScreen.select(EmpireOverviewCategories.Units, UnitOverviewTab.getUnitIdentifier(garrisonUnit) )
}
detailsTable.add(garrisonUnitIcon)
}
detailsTable.row()
}
// row heights may diverge - fix it by setting minHeight to
// largest actual height (of the construction cell) - !! guarded by isEmpty test above
val largestLabelHeight = constructionCells.maxByOrNull{ it.prefHeight }!!.prefHeight
for (cell in constructionCells) cell.minHeight(largestLabelHeight)
detailsTable.pack()
}
private fun updateTotal() {
totalTable.add("Total".toLabel()).left()
totalTable.add() // construction icon column
totalTable.add().expandX() // construction label column
totalTable.add(viewingPlayer.cities.sumOf { it.population.population }.toCenteredLabel())
for (column in columnsNames.filter { it.isStat() }) {
val stat = Stat.valueOf(column)
if (stat == Stat.Food || stat == Stat.Production) totalTable.add() // an intended empty space
else totalTable.add(viewingPlayer.cities.sumOf { it.getStat(stat) }.toCenteredLabel())
}
totalTable.add(viewingPlayer.cities.count { it.isWeLoveTheKingDayActive() }.toCenteredLabel())
totalTable.add(viewingPlayer.cities.count { it.isGarrisoned() }.toCenteredLabel())
totalTable.pack()
add(grid)
}
}

View File

@ -0,0 +1,218 @@
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.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.images.ImageGetter
import com.unciv.ui.screens.cityscreen.CityScreen
import kotlin.math.roundToInt
/**
* This defines all behaviour of the [CityOverviewTab] columns through overridable parts
*/
// This false positive of detekt is possibly fixed in https://github.com/detekt/detekt/pull/6367
// (The getComparator overrides need the explicit City type on their lambda parameter)
@Suppress("ExplicitItLambdaParameter") // detekt is wrong
enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOverviewScreen> {
//region Enum Instances
CityColumn {
override val headerTip = "Name"
override val align = Align.left
override val fillX = true
override val defaultDescending = false
override fun getComparator() = compareBy(collator) { it: City -> it.name.tr(hideIcons = true) }
override fun getHeaderIcon(iconSize: Float) =
ImageGetter.getUnitIcon("Settler")
.surroundWithCircle(iconSize)
override fun getEntryValue(item: City) = 0 // make sure that `stat!!` in the super isn't used
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen) =
item.name.toTextButton(hideIcons = true)
.onClick {
actionContext.game.pushScreen(CityScreen(item))
}
override fun getTotalsActor(items: Iterable<City>) =
"Total".toLabel()
},
ConstructionIcon {
override fun getHeaderIcon(iconSize: Float) = null
override fun getEntryValue(item: City) =
item.cityConstructions.run { turnsToConstruction(currentConstructionFromQueue) }
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen): Actor? {
val construction = item.cityConstructions.currentConstructionFromQueue
if (construction.isEmpty()) return null
return ImageGetter.getConstructionPortrait(construction, iconSize * 0.8f)
}
override fun getTotalsActor(items: Iterable<City>) = null
},
Construction {
override val align = Align.left
override val expandX = true
override val equalizeHeight = true
override val headerTip = "Current construction"
override val defaultDescending = false
override fun getComparator() =
compareBy(collator) { it: City -> it.cityConstructions.currentConstructionFromQueue.tr(hideIcons = true) }
override fun getHeaderIcon(iconSize: Float) =
getCircledIcon("OtherIcons/Settings", iconSize)
override fun getEntryValue(item: City) = 0
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen) =
item.cityConstructions.getCityProductionTextForCityButton().toLabel()
override fun getTotalsActor(items: Iterable<City>) = null
},
Population {
override fun getEntryValue(item: City) =
item.population.population
},
Food {
override fun getTotalsActor(items: Iterable<City>) = null // an intended empty space
},
Gold,
Science,
Production{
override fun getTotalsActor(items: Iterable<City>) = null // an intended empty space
},
Culture,
Happiness {
override fun getEntryValue(item: City) =
item.cityStats.happinessList.values.sum().roundToInt()
},
Faith {
override fun isVisible(gameInfo: GameInfo) =
gameInfo.isReligionEnabled()
},
WLTK {
override val headerTip = "We Love The King Day"
override val defaultDescending = false
override fun getComparator() =
super.getComparator().thenBy { it.demandedResource.tr(hideIcons = true) }
override fun getHeaderIcon(iconSize: Float) =
getCircledIcon("OtherIcons/WLTK 2", iconSize, Color.TAN)
override fun getEntryValue(item: City) =
if (item.isWeLoveTheKingDayActive()) 1 else 0
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen) = when {
item.isWeLoveTheKingDayActive() -> {
ImageGetter.getImage("OtherIcons/WLTK 1")
.surroundWithCircle(iconSize, color = Color.CLEAR)
.apply {
addTooltip("[${item.getFlag(CityFlags.WeLoveTheKing)}] turns", 18f, tipAlign = Align.topLeft)
}
}
item.demandedResource.isNotEmpty() -> {
ImageGetter.getResourcePortrait(item.demandedResource, iconSize * 0.7f).apply {
addTooltip("Demanding [${item.demandedResource}]", 18f, tipAlign = Align.topLeft)
onClick { actionContext.showOneTimeNotification(
item.civ.gameInfo.getExploredResourcesNotification(item.civ, item.demandedResource)
) }
}
}
else -> null
}
},
Garrison {
override val headerTip = "Garrisoned by unit"
override val defaultDescending = false
override fun getComparator() =
compareBy(collator) { it: City -> it.getCenterTile().militaryUnit?.name?.tr(hideIcons = true) ?: "" }
override fun getHeaderIcon(iconSize: Float) =
getCircledIcon("OtherIcons/Shield", iconSize)
override fun getEntryValue(item: City) =
if (item.getCenterTile().militaryUnit != null) 1 else 0
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen): Actor? {
val unit = item.getCenterTile().militaryUnit ?: return null
val unitName = unit.displayName()
val unitIcon = ImageGetter.getConstructionPortrait(unit.baseUnit.getIconName(), iconSize * 0.7f)
unitIcon.addTooltip(unitName, 18f, tipAlign = Align.topLeft)
unitIcon.onClick {
actionContext.select(EmpireOverviewCategories.Units, UnitOverviewTab.getUnitIdentifier(unit) )
}
return unitIcon
}
},
;
//endregion
/** The Stat constant if this is a Stat column - helps the default getter methods */
private val stat = Stat.safeValueOf(name)
//region Overridable fields
override val headerTip get() = name
override val align = Align.center
override val fillX = false
override val expandX = false
override val equalizeHeight = false
override val defaultDescending = true
//endregion
//region Overridable methods
/** Factory for the header cell [Actor]
* - Must override unless a texture exists for "StatIcons/$name" - e.g. a [Stat] column or [Population].
* - _Should_ be sized to [iconSize].
*/
override fun getHeaderIcon(iconSize: Float): Actor? =
ImageGetter.getStatIcon(name)
/** A getter for the numeric value to display in a cell
* - The default implementation works only on [Stat] columns, so an override is mandatory unless
* it's a [Stat] _or_ all three methods mentioned below have overrides.
* - By default this feeds [getComparator], [getEntryActor] _and_ [getTotalsActor],
* so an override may be useful for sorting and/or a total even if you do override [getEntryActor].
*/
override fun getEntryValue(item: City): Int =
item.cityStats.currentCityStats[stat!!].roundToInt()
/** Factory for entry cell [Actor]
* - By default displays the (numeric) result of [getEntryValue].
* - [actionContext] will be the parent screen used to define `onClick` actions.
*/
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen): Actor? =
getEntryValue(item).toCenteredLabel()
//endregion
/** Factory for totals cell [Actor]
* - By default displays the sum over [getEntryValue].
* - Note a count may be meaningful even if entry cells display something other than a number,
* In that case _not_ overriding this and supply a meaningful [getEntryValue] may be easier.
* - 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`.
*/
override fun getTotalsActor(items: Iterable<City>): 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) }
}
}

View File

@ -2,7 +2,9 @@ package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.unciv.Constants
import com.unciv.GUI
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.Notification
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.images.ImageGetter
@ -98,4 +100,15 @@ class EmpireOverviewScreen(
val scrollY = tab.select(selection) ?: return
tabbedPager.setPageScrollY(tabbedPager.activePage, scrollY)
}
/** Helper to show the world screen with a temporary "one-time" notification */
// Here because it's common to notification history, resource finder, and city WLTK demanded resource
internal fun showOneTimeNotification(notification: Notification?) {
if (notification == null) return // Convenience - easier than a return@lambda for a caller
val worldScreen = GUI.getWorldScreen()
worldScreen.notificationsScroll.oneTimeNotification = notification
GUI.resetToWorldScreen()
notification.resetExecuteRoundRobin()
notification.execute(worldScreen)
}
}

View File

@ -1,10 +1,7 @@
package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.GUI
import com.unciv.UncivGame
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.Notification
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.screens.basescreen.BaseScreen
@ -28,15 +25,4 @@ abstract class EmpireOverviewTab (
open fun select(selection: String): Float? = null
val gameInfo = viewingPlayer.gameInfo
/** Helper to show the world screen with a temporary "one-time" notification */
// Here because it's common to notification history and resource finder
internal fun showOneTimeNotification(notification: Notification?) {
if (notification == null) return // Convenience - easier than a return@lambda for a caller
val worldScreen = GUI.getWorldScreen()
worldScreen.notificationsScroll.oneTimeNotification = notification
UncivGame.Current.resetToWorldScreen()
notification.resetExecuteRoundRobin()
notification.execute(worldScreen)
}
}

View File

@ -89,7 +89,7 @@ class NotificationsOverviewTable(
notificationTable.background = BaseScreen.skinStrings.getUiBackground("OverviewScreen/NotificationOverviewTable/Notification", BaseScreen.skinStrings.roundedEdgeRectangleShape)
notificationTable.touchable = Touchable.enabled
if (notification.actions.isNotEmpty())
notificationTable.onClick { showOneTimeNotification(notification) }
notificationTable.onClick { overviewScreen.showOneTimeNotification(notification) }
notification.addNotificationIconsTo(notificationTable, gameInfo.ruleset, iconSize)

View File

@ -15,6 +15,7 @@ import com.unciv.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.addSeparatorVertical
import com.unciv.ui.components.extensions.equalizeColumns
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.surroundWithCircle
@ -79,7 +80,7 @@ class ResourcesOverviewTab(
val label = if (resource.isStockpiled() && amount > 0) "+$amount".toLabel()
else amount.toLabel()
if (origin == ExtraInfoOrigin.Unimproved.name)
label.onClick { showOneTimeNotification(
label.onClick { overviewScreen.showOneTimeNotification(
gameInfo.getExploredResourcesNotification(viewingPlayer, resource.name) {
it.getOwner() == viewingPlayer && it.countAsUnimproved()
}
@ -95,7 +96,7 @@ class ResourcesOverviewTab(
private fun getResourceImage(name: String) =
ImageGetter.getResourcePortrait(name, iconSize).apply {
onClick { showOneTimeNotification(
onClick { overviewScreen.showOneTimeNotification(
gameInfo.getExploredResourcesNotification(viewingPlayer, name)
) }
}

View File

@ -24,6 +24,7 @@ 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
@ -37,6 +38,7 @@ 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
*/

View File

@ -12,8 +12,9 @@ import com.unciv.models.ruleset.QuestName
import com.unciv.models.ruleset.tech.Era
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.translations.tr
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.equalizeColumns
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen

View File

@ -6,6 +6,7 @@ import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.equalizeColumns
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen

View File

@ -7,6 +7,7 @@ import com.unciv.logic.civilization.Civilization
import com.unciv.models.ruleset.Victory
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.equalizeColumns
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen

View File

@ -10,6 +10,7 @@ import com.unciv.models.ruleset.MilestoneType
import com.unciv.models.ruleset.Victory
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.equalizeColumns
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen