Move parsing of localized numbers to UncivTextField (#13550)

* Move parsing of localized numbers to UncivTextField

* Docs!
This commit is contained in:
SomeTroglodyte 2025-07-03 10:02:36 +02:00 committed by GitHub
parent 0846b6d486
commit 02d3c110aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 123 additions and 125 deletions

View File

@ -12,7 +12,6 @@ import com.unciv.ui.components.fonts.DiacriticSupport
import com.unciv.ui.components.fonts.FontRulesetIcons
import com.unciv.utils.Log
import com.unciv.utils.debug
import java.text.ParseException
import java.util.Locale
import org.jetbrains.annotations.VisibleForTesting
@ -544,44 +543,22 @@ fun String.removeConditionals(): String {
.trim()
}
// formats number according to current language
/** Formats number according to current language
*
* Note: The inverse operation is UncivGame.Current.settings.getCurrentNumberFormat().parse(string), handled in the [UncivTextField.Numeric][com.unciv.ui.components.widgets.UncivTextField.Numeric] widget.
*
* @return locale-dependent String representation of receiver, may contain formatting like thousands separators
*/
fun Number.tr(): String {
return UncivGame.Current.settings.getCurrentNumberFormat().format(this)
}
/**
* Parses the string as an integer using the current number format.
/** Formats number according to a specific [language]
*
* Empty strings result in 0.
* Note: The inverse operation is `LocaleCode.getNumberFormatFromLanguage(language).parse(string)`.
*
* @return The integer value, or null if parsing fails.
* @return locale-dependent String representation of receiver, may contain formatting like thousands separators
*/
fun String.toIntOrNullTranslated(): Int? {
if (isEmpty()) return 0
return try {
UncivGame.Current.settings.getCurrentNumberFormat().parse(this).toInt()
} catch (_: ParseException) {
this.toIntOrNull()
}
}
// formats number according to given language
fun Number.tr(language: String): String {
return LocaleCode.getNumberFormatFromLanguage(language).format(this)
}
/**
* Parses the string as an integer using the current number format.
*
* Empty strings result in 0.
*
* @return The integer value, or null if parsing fails.
*/
fun String.toIntOrNullTranslated(language: String): Int? {
if (isEmpty()) return 0
return try {
LocaleCode.getNumberFormatFromLanguage(language).parse(this).toInt()
} catch (_: ParseException) {
this.toIntOrNull()
}
}

View File

@ -9,6 +9,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.scenes.scene2d.utils.FocusListener
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.event.EventBus
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.getAscendant
@ -22,6 +23,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.UncivStage
import com.unciv.utils.Concurrency
import com.unciv.utils.withGLContext
import java.text.ParseException
import kotlinx.coroutines.delay
/**
@ -38,7 +40,7 @@ import kotlinx.coroutines.delay
* @param onFocusChange a callback that will be notified when this TextField loses or gains the [keyboardFocus][com.badlogic.gdx.scenes.scene2d.Stage.keyboardFocus].
* Receiver is the field, so you can simply use its elements. Parameter `it` is a Boolean indicating focus was received.
*/
class UncivTextField(
open class UncivTextField(
hint: String,
preEnteredText: String = "",
private val onFocusChange: (TextField.(Boolean) -> Unit)? = null
@ -49,9 +51,9 @@ class UncivTextField(
init {
messageText = hint.tr()
addListener(UncivTextFieldFocusListener())
this.addListener(UncivTextFieldFocusListener())
if (isAndroid) addListener(VisibleAreaChangedListener())
if (isAndroid) this.addListener(VisibleAreaChangedListener())
}
private inner class UncivTextFieldFocusListener : FocusListener() {
@ -188,4 +190,66 @@ class UncivTextField(
}
}
}
/**
* Specialization of [UncivTextField] for numbers. See its documentation for improvements over [TextField].
* - Note: It uses the generic [Number] class and thus supports both floating-point and integer as input.
* You might need a conversion when retrieving the result.
* - Note: [maxLength] is set to 26, but you can change it later.
* - Limitation: Depending on Java's decisions for your locale, the displayed string will likely contain thousands separators.
* These are ignored when parsing!
* However, user input will not update them, fix misplaced ones, or add them to pasted numbers.
*
* @property value Gets/sets the [text], using localized formatting according to the language chosen in the settings, in both directions.
* Note null is allowed and represents an empty text field.
* @property setText Forbidden, use [value] instead.
* @param hint See [UncivTextField].
* @param initialValue Sets the initial [value] without triggering a `ChangeEvent.`
* @param integerOnly Limits allowed characters and tells the parser to look for integers only.
* @param onFocusChange See [UncivTextField].
*/
open class Numeric(
hint: String,
initialValue: Number?,
integerOnly: Boolean = false,
onFocusChange: (TextField.(Boolean) -> Unit)? = null
) : UncivTextField(hint, initialValue?.tr() ?: "", onFocusChange) {
private val formatter = UncivGame.Current.settings.getCurrentNumberFormat()
private val symbols = formatter.format(if (integerOnly) -9999 else -9999.9).filter { !it.isDigit() }
init {
textFieldFilter = TextFieldFilter { _, c -> c.isDigit() || c in symbols }
formatter.isParseIntegerOnly = integerOnly
maxLength = 26 // enough for signed int64 including thousands separators - floating point shouldn't need more.
}
open var value: Number?
get() = try {
formatter.parse(text)
} catch (_: ParseException) {
null
}
set(value) { super.text = value?.tr() ?: "" }
// Enlist compiler to make sure no-one calls this *from our project*
@Deprecated("Don't assign `text` on a numeric UncivTextField!", ReplaceWith("value"), DeprecationLevel.ERROR)
// But: Gdx is leaking `this` and calls this override from its constructor, therefore don't throw.
override fun setText(str: String?) = super.setText(str)
}
/**
* Specialization of [UncivTextField.Numeric] for 32-bit integers. Do read its Kdoc.
* - maxLength is reduced to 14 accommodating the largest negative 32-bit integer with thousands separators.
* @property intValue please prefer this over [value], it's easier!
*/
class Integer (
hint: String,
initialValue: Int?,
onFocusChange: (TextField.(Boolean) -> Unit)? = null
) : Numeric(hint, initialValue, integerOnly = true, onFocusChange) {
init {
maxLength = 14
}
var intValue: Int?
get() = value?.toInt()
set(value) { this.value = value }
}
}

View File

@ -3,9 +3,6 @@ package com.unciv.ui.popups
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.models.translations.toIntOrNullTranslated
import com.unciv.models.translations.tr
import com.unciv.ui.components.widgets.UncivTextField
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.input.onClick
@ -32,71 +29,48 @@ class AskNumberPopup(
screen: BaseScreen,
label: String = "Please enter a number",
icon: IconCircleGroup = ImageGetter.getImage("OtherIcons/Pencil").apply { this.color = ImageGetter.CHARCOAL }.surroundWithCircle(80f),
defaultValue: String = "",
defaultValue: Int? = null,
amountButtons: List<Int> = listOf(),
bounds: IntRange = IntRange(Int.MIN_VALUE, Int.MAX_VALUE),
errorText: String = "Invalid input! Please enter a valid number.",
validate: (input: Int) -> Boolean = { true },
actionOnOk: (input: Int) -> Unit = { },
): Popup(screen) {
/** Note for future developers: Why this class only accepts positive digits and not negative.
*
* The problems is the minus sign. This might not seem like a large obstacle, but problems
* arrive quickly. First is that our clean `DigitsOnlyFilter()` must be replaced with a check
* that allows for adding a minus sign, but only when it is the first character. So far so good,
* until a user starts typing numbers before an already placed - sign --> crash. Fix that
* by disallowing any character being typed in front of a - sign. All is fixed right? Wrong!
* Because you now also disallowed writing two minus signs at the same time, copying over a
* new number after clamping now disallows overwriting the existing minus sign with a new minus
* sign, as there is already a minus sign in the number. Well, no problem, you can just remove
* the number before overwriting it with the clamped variant. But now you reset your cursor
* position every time you type a character. You might start trying to cache the cursor position
* as well, but at that point you're basically rewriting the setText() function, and when I
* reached this point I decided to stop.
*
* P.S., if you do decide to go on this quest of adding minus signs, don't forget that
* `"-".toInt()` also crashes, so you need to exclude that before checking to clamp.
*/
init {
val wrapper = Table()
wrapper.add(icon).padRight(10f)
wrapper.add(label.toLabel())
add(wrapper).colspan(2).row()
val nameField = UncivTextField(label, defaultValue)
nameField.textFieldFilter = TextField.TextFieldFilter { _, char -> char.isDigit() || char == '-' }
val nameField = UncivTextField.Integer(label, defaultValue)
fun isValidInt(input: String): Boolean = input.toIntOrNullTranslated() != null
fun getInt(input: String): Int? = input.toIntOrNullTranslated()
fun clampInBounds(input: Int?): Int? {
if (input == null) return null
fun clampInBounds(input: String): String {
val int = getInt(input) ?: return input
if (bounds.first > int) {
return bounds.first.tr()
if (bounds.first > input) {
return bounds.first
}
if (bounds.last < int)
return bounds.last.tr()
if (bounds.last < input)
return bounds.last
return input
}
nameField.onChange {
nameField.text = clampInBounds(nameField.text)
nameField.intValue = clampInBounds(nameField.intValue)
}
val centerTable = Table(skin)
fun addValueButton(value: Int) {
fun addValueButton(delta: Int) {
centerTable.add(
Button(
value.toStringSigned().toLabel(),
delta.toStringSigned().toLabel(),
skin
).apply {
onClick {
if (isValidInt(nameField.text))
nameField.text = clampInBounds((getInt(nameField.text)!! + value).tr())
val value = nameField.intValue ?: return@onClick
nameField.intValue = value + delta
}
}
).pad(5f)
@ -120,12 +94,12 @@ class AskNumberPopup(
addCloseButton()
addOKButton(
validate = {
val errorFound = getInt(nameField.text)?.let { validate(it) } != true
val errorFound = nameField.intValue?.let { validate(it) } != true
if (errorFound) add(errorLabel).colspan(2).center()
!errorFound
}
) {
actionOnOk(getInt(nameField.text)!!)
actionOnOk(nameField.intValue!!)
}
equalizeLastTwoButtonWidths()

View File

@ -10,7 +10,6 @@ import com.unciv.logic.files.MapSaver
import com.unciv.logic.files.UncivFiles
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.translations.tr
import com.unciv.ui.components.widgets.UncivTextField
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.toCheckBox
@ -32,10 +31,10 @@ fun debugTab(
if (GUI.isWorldLoaded()) {
val simulateButton = "Simulate until turn:".toTextButton()
val simulateTextField = UncivTextField("Turn", DebugUtils.SIMULATE_UNTIL_TURN.tr())
val simulateTextField = UncivTextField.Numeric("Turn", DebugUtils.SIMULATE_UNTIL_TURN, integerOnly = true)
val invalidInputLabel = "This is not a valid integer!".toLabel().also { it.isVisible = false }
simulateButton.onClick {
val simulateUntilTurns = simulateTextField.text.toIntOrNull()
val simulateUntilTurns = simulateTextField.value?.toInt()
if (simulateUntilTurns == null) {
invalidInputLabel.isVisible = true
return@onClick

View File

@ -120,7 +120,7 @@ class OfferColumnsTable(
screen,
label = "Enter the amount of gold",
icon = ImageGetter.getStatIcon("Gold").surroundWithCircle(80f),
defaultValue = offer.amount.tr(),
defaultValue = offer.amount,
amountButtons =
if (offer.type == TradeOfferType.Gold) listOf(50, 500)
else listOf(5, 15),

View File

@ -73,9 +73,9 @@ class MapEditorGenerateTab(
Concurrency.runOnGLThread {
ToastPopup( message, editorScreen, 4000 )
newTab.mapParametersTable.run { mapParameters.mapSize.also {
customMapSizeRadius.text = it.radius.tr()
customMapWidth.text = it.width.tr()
customMapHeight.text = it.height.tr()
customMapSizeRadius.intValue = it.radius
customMapWidth.intValue = it.width
customMapHeight.intValue = it.height
} }
}
return
@ -196,7 +196,7 @@ class MapEditorGenerateTab(
defaults().pad(2.5f)
add("Generator steps".toLabel(fontSize = 24)).row()
optionGroup.setMinCheckCount(0)
for (option in MapGeneratorSteps.values()) {
for (option in MapGeneratorSteps.entries) {
if (option <= MapGeneratorSteps.All) continue
val checkBox = option.label.toCheckBox {
choice = option

View File

@ -3,8 +3,6 @@ package com.unciv.ui.screens.newgamescreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.scenes.scene2d.ui.TextField.TextFieldFilter.DigitsOnlyFilter
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.map.*
@ -12,7 +10,6 @@ import com.unciv.logic.map.mapgenerator.MapGenerator
import com.unciv.logic.map.mapgenerator.MapResourceSetting
import com.unciv.models.metadata.GameParameters
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.*
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.input.onClick
@ -34,12 +31,12 @@ class MapParametersTable(
private val forMapEditor: Boolean = false,
private val sizeChangedCallback: (()->Unit)? = null
) : Table() {
// These are accessed fom outside the class to read _and_ write values,
// These are accessed from outside the class to read _and_ write values,
// namely from MapOptionsTable, NewMapScreen and NewGameScreen
lateinit var mapTypeSelectBox: TranslatedSelectBox
lateinit var customMapSizeRadius: TextField
lateinit var customMapWidth: TextField
lateinit var customMapHeight: TextField
lateinit var customMapSizeRadius: UncivTextField.Integer
lateinit var customMapWidth: UncivTextField.Integer
lateinit var customMapHeight: UncivTextField.Integer
private lateinit var worldSizeSelectBox: TranslatedSelectBox
private var customWorldSizeTable = Table()
@ -51,13 +48,13 @@ class MapParametersTable(
private lateinit var worldWrapCheckbox: CheckBox
private lateinit var legendaryStartCheckbox: CheckBox
private lateinit var strategicBalanceCheckbox: CheckBox
private lateinit var seedTextField: TextField
private lateinit var seedTextField: UncivTextField.Numeric
private lateinit var mapShapesOptionsValues: HashSet<String>
private lateinit var mapTypesOptionsValues: HashSet<String>
private lateinit var mapSizesOptionsValues: HashSet<String>
private lateinit var mapResourcesOptionsValues: HashSet<String>
private val maxMapSize = ((previousScreen as? NewGameScreen)?.getColumnWidth() ?: 200f) - 10f // There is 5px padding each side
private val mapTypeExample = Table()
@ -98,7 +95,7 @@ class MapParametersTable(
fun reseed() {
mapParameters.reseed()
seedTextField.text = mapParameters.seed.tr()
seedTextField.value = mapParameters.seed
}
private fun addMapShapeSelectBox() {
@ -131,7 +128,7 @@ class MapParametersTable(
add(mapShapeSelectBox).fillX().row()
}
}
private fun generateExampleMap(){
val ruleset = if (previousScreen is NewGameScreen) previousScreen.ruleset else RulesetCache.getVanillaRuleset()
Concurrency.run("Generate example map") {
@ -181,7 +178,7 @@ class MapParametersTable(
add(optionsTable).colspan(2).grow().row()
} else {
mapTypeSelectBox = TranslatedSelectBox(mapTypes, mapParameters.type)
mapTypeSelectBox.onChange {
mapParameters.type = mapTypeSelectBox.selected.value
@ -189,7 +186,7 @@ class MapParametersTable(
// If the map won't be generated, these options are irrelevant and are hidden
noRuinsCheckbox.isVisible = mapParameters.type != MapType.empty
noNaturalWondersCheckbox.isVisible = mapParameters.type != MapType.empty
generateExampleMap()
}
@ -228,12 +225,10 @@ class MapParametersTable(
}
private fun addHexagonalSizeTable() {
val defaultRadius = mapParameters.mapSize.radius.tr()
customMapSizeRadius = UncivTextField("Radius", defaultRadius).apply {
textFieldFilter = DigitsOnlyFilter()
}
val defaultRadius = mapParameters.mapSize.radius
customMapSizeRadius = UncivTextField.Integer("Radius", defaultRadius)
customMapSizeRadius.onChange {
mapParameters.mapSize = MapSize(customMapSizeRadius.text.toIntOrNull() ?: 0 )
mapParameters.mapSize = MapSize(customMapSizeRadius.intValue ?: 0 )
}
hexagonalSizeTable.add("{Radius}:".toLabel()).grow().left()
hexagonalSizeTable.add(customMapSizeRadius).right().row()
@ -242,21 +237,16 @@ class MapParametersTable(
}
private fun addRectangularSizeTable() {
val defaultWidth = mapParameters.mapSize.width.tr()
customMapWidth = UncivTextField("Width", defaultWidth).apply {
textFieldFilter = DigitsOnlyFilter()
}
val defaultHeight = mapParameters.mapSize.height.tr()
customMapHeight = UncivTextField("Height", defaultHeight).apply {
textFieldFilter = DigitsOnlyFilter()
}
val defaultWidth = mapParameters.mapSize.width
customMapWidth = UncivTextField.Integer("Width", defaultWidth)
val defaultHeight = mapParameters.mapSize.height
customMapHeight = UncivTextField.Integer("Height", defaultHeight)
customMapWidth.onChange {
mapParameters.mapSize = MapSize(customMapWidth.text.toIntOrNull() ?: 0, customMapHeight.text.toIntOrNull() ?: 0)
mapParameters.mapSize = MapSize(customMapWidth.intValue ?: 0, customMapHeight.intValue ?: 0)
}
customMapHeight.onChange {
mapParameters.mapSize = MapSize(customMapWidth.text.toIntOrNull() ?: 0, customMapHeight.text.toIntOrNull() ?: 0)
mapParameters.mapSize = MapSize(customMapWidth.intValue ?: 0, customMapHeight.intValue ?: 0)
}
rectangularSizeTable.defaults().pad(5f)
@ -384,16 +374,10 @@ class MapParametersTable(
private fun addAdvancedControls(table: Table) {
table.defaults().pad(2f).padTop(10f)
seedTextField = UncivTextField("RNG Seed", mapParameters.seed.tr())
seedTextField.textFieldFilter = DigitsOnlyFilter()
seedTextField = UncivTextField.Numeric("RNG Seed", mapParameters.seed, integerOnly = true)
// If the field is empty, fallback seed value to 0
seedTextField.onChange {
mapParameters.seed = try {
seedTextField.text.toLong()
} catch (_: Exception) {
0L
}
mapParameters.seed = seedTextField.value?.toLong() ?: 0L
}
table.add("RNG Seed".toLabel()).left()
@ -464,7 +448,7 @@ class MapParametersTable(
addTextButton("Reset to defaults", true) {
mapParameters.resetAdvancedSettings()
seedTextField.text = mapParameters.seed.tr()
seedTextField.value = mapParameters.seed
for (entry in advancedSliders)
entry.key.value = entry.value()
}

View File

@ -193,9 +193,9 @@ class NewGameScreen(
val message = mapSize.fixUndesiredSizes(gameSetupInfo.mapParameters.worldWrap)
if (message != null) {
with (mapOptionsTable.generatedMapOptionsTable) {
customMapSizeRadius.text = mapSize.radius.tr()
customMapWidth.text = mapSize.width.tr()
customMapHeight.text = mapSize.height.tr()
customMapSizeRadius.intValue = mapSize.radius
customMapWidth.intValue = mapSize.width
customMapHeight.intValue = mapSize.height
}
return message
}