Make CityScreen's top-right widget use an ExpanderTab (#13186)

* Make CityScreen's top-right widget use an ExpanderTab

* No nasty tricks please
This commit is contained in:
SomeTroglodyte 2025-04-17 22:03:03 +02:00 committed by GitHub
parent adfaacb0f6
commit a5a148cc51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 117 additions and 80 deletions

View File

@ -5,13 +5,17 @@ import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.actions.FloatAction
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.Value
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.ActivationTypes
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.clearActivationActions
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.images.IconCircleGroup
@ -25,6 +29,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen
* @param fontSize Size applied to header text (only)
* @param icon Optional icon - please use [Image][com.badlogic.gdx.scenes.scene2d.ui.Image] or [IconCircleGroup]
* @param defaultPad Padding between content and wrapper.
* @param topPad Padding between content top and wrapper.
* @param headerPad Default padding for the header Table.
* @param expanderWidth If set initializes header width
* @param expanderHeight If set initializes header height
@ -38,6 +43,7 @@ class ExpanderTab(
icon: Actor? = null,
startsOutOpened: Boolean = true,
defaultPad: Float = 10f,
topPad: Float = defaultPad,
headerPad: Float = 10f,
expanderWidth: Float = 0f,
expanderHeight: Float = 0f,
@ -46,13 +52,15 @@ class ExpanderTab(
private val onChange: (() -> Unit)? = null,
initContent: ((Table) -> Unit)? = null
): Table(BaseScreen.skin) {
private companion object {
const val arrowSize = 18f
const val arrowImage = "OtherIcons/BackArrow"
val arrowColor = Color(1f,0.96f,0.75f,1f)
const val animationDuration = 0.2f
companion object {
private const val arrowSize = 18f
private const val arrowImage = "OtherIcons/BackArrow"
private val arrowColor = Color(1f,0.96f,0.75f,1f)
private const val animationDuration = 0.2f
val persistedStates = HashMap<String, Boolean>()
private val persistedStates = HashMap<String, Boolean>()
fun wasOpen(persistenceID: String) = persistedStates[persistenceID]
}
/** Header with label, [headerContent] and icon, touchable to show/hide.
@ -62,9 +70,9 @@ class ExpanderTab(
/** Additional elements can be added to the `ExpanderTab`'s header using this container, empty by default. */
val headerContent = Table()
private val headerLabel = title.toLabel(fontSize = fontSize, hideIcons = true)
private val headerIcon = ImageGetter.getImage(arrowImage)
val headerIcon = ImageGetter.getImage(arrowImage)
private val contentWrapper = Table() // Wrapper for innerTable, this is what will be shown/hidden
/** The container where the client should add the content to toggle */
@ -107,7 +115,7 @@ class ExpanderTab(
if (expanderWidth != 0f)
defaults().minWidth(expanderWidth)
defaults().growX()
contentWrapper.defaults().growX().pad(defaultPad)
contentWrapper.defaults().growX().pad(topPad, defaultPad, defaultPad, defaultPad)
innerTable.defaults().growX()
add(header).fill().row()
add(contentWrapper)
@ -189,4 +197,24 @@ class ExpanderTab(
fun setText(text: String) {
headerLabel.setText(text)
}
fun toggleOnIconOnly() {
header.clearActivationActions(ActivationTypes.Tap)
headerIcon.onActivation { toggle() }
}
private fun Cell<Actor>.resetFixedSize(): Cell<Actor> {
minHeight(Value.minHeight)
prefHeight(Value.prefHeight)
maxHeight(Value.maxHeight)
minWidth(Value.minWidth)
prefWidth(Value.prefHeight)
maxWidth(Value.maxWidth)
return this
}
fun setDynamicHeaderSize() {
header.cells[0]!!.resetFixedSize().center()
header.cells[1]!!.resetFixedSize().center()
}
}

View File

@ -2,9 +2,10 @@ package com.unciv.ui.screens.cityscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Touchable
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.scenes.scene2d.ui.Value
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.logic.city.*
@ -16,30 +17,27 @@ import com.unciv.models.stats.Stat
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.*
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.input.ActivationTypes
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.clearActivationActions
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.AutoScrollPane
import com.unciv.ui.components.widgets.ExpanderTab
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import kotlin.math.ceil
import kotlin.math.round
import com.unciv.ui.components.widgets.AutoScrollPane as ScrollPane
class CityStatsTable(private val cityScreen: CityScreen) : Table() {
private val innerTable = Table() // table within this Table. Slightly smaller creates border
private val upperTable = Table() // fixed position table
private val lowerTable = Table() // table that will be in the ScrollPane
private val lowerPane: ScrollPane
private val city = cityScreen.city
private val headerIcon = ImageGetter.getImage("OtherIcons/BackArrow").apply {
setSize(18f, 18f)
setOrigin(Align.center)
rotation = 90f
}
private var headerIconClickArea = Table()
private var isOpen = !cityScreen.isCrampedPortrait()
private val expander: ExpanderTab
// table within this Table. Slightly smaller creates border
private val miniStatsTable = MiniStatsTable(ExpanderTab.wasOpen("CityStatsTable"))
private val lowerTable = Table() // table that will be in the ScrollPane
private val lowerPane = AutoScrollPane(lowerTable)
private var lowerCell: Cell<AutoScrollPane>? = null
private val detailedStatsButton = "Stats".toTextButton().apply {
labelCell.pad(10f)
onActivation(binding = KeyboardBinding.ShowStats) {
@ -54,61 +52,43 @@ class CityStatsTable(private val cityScreen: CityScreen) : Table() {
tintColor = colorFromRGB(194, 180, 131)
)
innerTable.pad(5f)
innerTable.background = BaseScreen.skinStrings.getUiBackground(
expander = ExpanderTab("",
startsOutOpened = !cityScreen.isCrampedPortrait(),
persistenceID = "CityStatsTable",
defaultPad = 7f,
headerPad = if (cityScreen.isCrampedPortrait()) 7f else 6f,
topPad = 0f, // remove space between miniStatsTable and detailedStatsButton
expanderWidth = miniStatsTable.width,
expanderHeight = miniStatsTable.height,
onChange = {
cityScreen.updateWithoutConstructionAndMap()
}
) {
lowerCell = it.add(lowerPane).grow()
}
expander.headerContent.add(miniStatsTable).growX()
expander.background = BaseScreen.skinStrings.getUiBackground(
"CityScreen/CityStatsTable/InnerTable",
tintColor = ImageGetter.CHARCOAL.cpy().apply { a = 0.8f }
)
expander.header.background = null // Make header transparent
/** Without this, the expander will keep initial header height, even when the
* row count of miniStatsTable changes when opening/closing in portrait mode */
expander.setDynamicHeaderSize()
// Don't toggle expander when clicking the stats icons
expander.toggleOnIconOnly()
upperTable.defaults().pad(2f)
lowerTable.defaults().pad(2f)
lowerPane = ScrollPane(lowerTable)
lowerPane.setOverscroll(false, false)
lowerPane.setScrollingDisabled(true, false)
lowerPane.setScrollingDisabled(x = true, y = false)
lowerTable.defaults().space(4f)
add(innerTable).growX()
// collapse icon with larger click area
headerIconClickArea.add(headerIcon).size(headerIcon.width).pad(6f+2f, 12f, 6f, 2f )
headerIconClickArea.touchable = Touchable.enabled
headerIconClickArea.onClick {
isOpen = !isOpen
cityScreen.updateWithoutConstructionAndMap()
}
add(expander).growX()
}
fun update(height: Float) {
upperTable.clear()
lowerTable.clear()
miniStatsTable.update()
val miniStatsTable = Table()
val selected = BaseScreen.skin.getColor("selection")
for ((stat, amount) in city.cityStats.currentCityStats) {
if (stat == Stat.Faith && !city.civ.gameInfo.isReligionEnabled()) continue
val icon = Table()
val focus = CityFocus.safeValueOf(stat)
val toggledFocus = if (focus == city.getCityFocus()) {
icon.add(ImageGetter.getStatIcon(stat.name).surroundWithCircle(27f, false, color = selected))
CityFocus.NoFocus
} else {
icon.add(ImageGetter.getStatIcon(stat.name).surroundWithCircle(27f, false, color = Color.CLEAR))
focus
}
if (cityScreen.canCityBeChanged()) {
icon.onActivation(binding = toggledFocus.binding) {
city.setCityFocus(toggledFocus)
city.reassignPopulation()
cityScreen.update()
}
}
miniStatsTable.add(icon).size(27f).padRight(3f)
val valueToDisplay = if (stat == Stat.Happiness) city.cityStats.happinessList.values.sum() else amount
miniStatsTable.add(round(valueToDisplay).toInt().toLabel()).padRight(5f)
if (cityScreen.isCrampedPortrait() && !isOpen && stat == Stat.Gold) {
miniStatsTable.row()
}
}
upperTable.add(miniStatsTable).expandX()
lowerTable.clear()
lowerTable.add(detailedStatsButton).row()
addText()
@ -124,22 +104,12 @@ class CityStatsTable(private val cityScreen: CityScreen) : Table() {
addBuildingsInfo()
headerIcon.rotation = if(isOpen) 90f else 0f
innerTable.clear()
innerTable.add(upperTable).expandX()
innerTable.add(headerIconClickArea).row()
val lowerCell = if (isOpen) {
innerTable.add(lowerPane).colspan(2)
} else null
upperTable.pack()
lowerTable.pack()
lowerPane.layout()
lowerPane.updateVisualScroll()
lowerCell?.maxHeight(height - upperTable.height - 8f) // 2 on each side of each cell in innerTable
lowerCell?.maxHeight(height - expander.header.height - 8f) // 2 on each side of each cell in expander
innerTable.pack() // update innerTable
expander.pack() // update expander
pack() // update self last
}
@ -417,4 +387,43 @@ class CityStatsTable(private val cityScreen: CityScreen) : Table() {
lowerTable.addCategory("Great People", greatPeopleTable, KeyboardBinding.GreatPeopleDetail)
}
private inner class MiniStatsTable(wasOpen: Boolean?) : Table() {
// Challenge: we want this measured before instantating the ExpanderTab.
// Ergo: update() must not access expander until _after_ init
init {
update(wasOpen)
pack()
}
fun update() = update(expander.isOpen)
private fun update(expanderIsOpen: Boolean?) {
clear()
val selected = BaseScreen.skin.getColor("selection")
for ((stat, amount) in city.cityStats.currentCityStats) {
if (stat == Stat.Faith && !city.civ.gameInfo.isReligionEnabled()) continue
val icon = Table()
val focus = CityFocus.safeValueOf(stat)
val toggledFocus = if (focus == city.getCityFocus()) {
icon.add(ImageGetter.getStatIcon(stat.name).surroundWithCircle(27f, false, color = selected))
CityFocus.NoFocus
} else {
icon.add(ImageGetter.getStatIcon(stat.name).surroundWithCircle(27f, false, color = Color.CLEAR))
focus
}
if (cityScreen.canCityBeChanged()) {
icon.onActivation(binding = toggledFocus.binding) {
city.setCityFocus(toggledFocus)
city.reassignPopulation()
cityScreen.update()
}
}
add(icon).size(27f).padRight(3f)
val valueToDisplay = if (stat == Stat.Happiness) city.cityStats.happinessList.values.sum() else amount
add(round(valueToDisplay).toInt().toLabel()).padRight(5f)
if (cityScreen.isCrampedPortrait() && (expanderIsOpen == null || !expanderIsOpen) && stat == Stat.Gold) {
row()
}
}
}
}
}