Make popups and text fields nicer to interact with on Android (#7211)

* Make popups and text fields nicer to interact with on Android

* Refactor: Rename createTextField to UncivTextField.create

* Fix multiplayer save delete text
This commit is contained in:
Timo T
2022-06-22 08:32:20 +02:00
committed by GitHub
parent a13a39293f
commit 68cc4303ec
27 changed files with 434 additions and 73 deletions

6
.gitignore vendored
View File

@ -24,7 +24,10 @@ www-test/
/android/libs/x86/
/android/libs/x86_64/
/android/gen/
.idea/
.idea/*
!/.idea/inspectionProfiles
/.idea/inspectionProfiles/*
!/.idea/inspectionProfiles/Project_Default.xml
*.ipr
*.iws
*.iml
@ -151,3 +154,4 @@ android/assets/music/
# Visual Studio Code
.vscode/
/.github/workflows/node_modules/*

View File

@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="000ce796-10c4-3e92-bd42-fa6f0bd6ec8b" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="SSBasedInspection" enabled="true" level="WARNING" enabled_by_default="true">
<searchConfiguration name="Raw TextField usage" description="We want to add some extra behavior to all text fields. This behavior will not be added by instantiating a TextField directly, so use our general factory function UncivTextField.create instead." suppressId="UNCIV_RAW_TEXTFIELD" problemDescriptor="Use UncivTextField.createTextField instead!" text="TextField($Params$)" recursive="true" caseInsensitive="true" type="Kotlin" pattern_context="default">
<constraint name="__context__" within="" contains="" />
<constraint name="Params" minCount="0" maxCount="2147483647" within="" contains="" />
</searchConfiguration>
</inspection_tool>
</profile>
</component>

View File

@ -1,19 +1,27 @@
package com.unciv.app
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.annotation.RequiresApi
import android.view.View
import android.view.ViewTreeObserver
import androidx.core.app.NotificationManagerCompat
import androidx.work.WorkManager
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.backends.android.AndroidApplication
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
import com.badlogic.gdx.backends.android.AndroidGraphics
import com.badlogic.gdx.math.Rectangle
import com.unciv.UncivGame
import com.unciv.UncivGameParameters
import com.unciv.logic.GameSaver
import com.unciv.logic.event.EventBus
import com.unciv.ui.UncivStage
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.Fonts
import com.unciv.utils.Log
import com.unciv.utils.concurrency.Concurrency
import java.io.File
open class AndroidLauncher : AndroidApplication() {
@ -53,6 +61,41 @@ open class AndroidLauncher : AndroidApplication() {
initialize(game, config)
setDeepLinkedGame(intent)
addScreenObscuredListener((Gdx.graphics as AndroidGraphics).view)
}
private fun addScreenObscuredListener(contentView: View) {
contentView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
/** [onGlobalLayout] gets triggered not only when the [windowVisibleDisplayFrame][View.getWindowVisibleDisplayFrame] changes, but also on other things.
* So we need to check if that was actually the thing that changed. */
private var lastVisibleDisplayFrame: Rect? = null
override fun onGlobalLayout() {
if (!UncivGame.isCurrentInitialized() || UncivGame.Current.screen == null) {
return
}
val r = Rect()
contentView.getWindowVisibleDisplayFrame(r)
if (r.equals(lastVisibleDisplayFrame)) return
lastVisibleDisplayFrame = r
val stage = (UncivGame.Current.screen as BaseScreen).stage
val horizontalRatio = stage.width / contentView.width
val verticalRatio = stage.height / contentView.height
val visibleStage = Rectangle(
r.left * horizontalRatio,
(contentView.height - r.bottom) * verticalRatio, // Android coordinate system has the origin in the top left, while GDX uses bottom left
r.width() * horizontalRatio,
r.height() * verticalRatio
)
Concurrency.runOnGLThread {
EventBus.send(UncivStage.VisibleAreaChanged(visibleStage))
}
}
})
}
/**

View File

@ -5,7 +5,7 @@ import android.content.pm.ActivityInfo
import android.os.Build
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
import androidx.annotation.RequiresApi
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.ui.utils.GeneralPlatformSpecificHelpers
import kotlin.concurrent.thread
@ -66,4 +66,8 @@ Sources for Info about current orientation in case need:
thread { throw ex } // this will kill the app but report the exception to the Google Play Console if the user allows it
return true
}
override fun addImprovements(textField: TextField): TextField {
return TextfieldImprovements.add(textField)
}
}

View File

@ -0,0 +1,113 @@
package com.unciv.app
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener
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.logic.event.EventBus
import com.unciv.models.translations.tr
import com.unciv.ui.UncivStage
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.extensions.getAscendant
import com.unciv.ui.utils.scrollAscendantToTextField
import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.withGLContext
import kotlinx.coroutines.delay
object TextfieldImprovements {
private val hideKeyboard = { Gdx.input.setOnscreenKeyboardVisible(false) }
fun add(textField: TextField): TextField {
textField.addListener(object : InputListener() {
private val events = EventBus.EventReceiver()
init {
events.receive(UncivStage.VisibleAreaChanged::class) {
if (textField.stage == null || !textField.hasKeyboardFocus()) return@receive
Concurrency.run {
// If anything resizes, it also does so with this event. So we need to wait for that to finish to update the scroll position.
delay(100)
withGLContext {
if (textField.stage == null) return@withGLContext
if (textField.scrollAscendantToTextField()) {
val scrollPane = textField.getAscendant { it is ScrollPane } as ScrollPane?
// when screen dimensions change, we don't want an animation for scrolling, just show, just show the textfield immediately
scrollPane?.updateVisualScroll()
} else {
// We can't scroll the text field into view, so we need to show a popup
TextfieldPopup(textField).open()
}
}
}
}
}
override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean {
addPopupCloseListener(textField)
return false
}
})
textField.addListener(object : FocusListener() {
override fun keyboardFocusChanged(event: FocusEvent?, actor: Actor?, focused: Boolean) {
if (focused) {
addPopupCloseListener(textField)
Gdx.input.setOnscreenKeyboardVisible(true)
}
}
})
return textField
}
private fun addPopupCloseListener(textField: TextField) {
val popup = textField.getAscendant { it is Popup } as Popup?
if (popup != null && !popup.closeListeners.contains(hideKeyboard)) {
popup.closeListeners.add(hideKeyboard)
}
}
}
class TextfieldPopup(
textField: TextField
) : Popup(textField.stage) {
val popupTextfield = clone(textField)
init {
addGoodSizedLabel(popupTextfield.messageText)
.colspan(2)
.row()
add(popupTextfield)
.width(stageToShowOn.width / 2)
.colspan(2)
.row()
addCloseButton("Cancel")
.left()
addOKButton { textField.text = popupTextfield.text }
.right()
.row()
showListeners.add {
stageToShowOn.keyboardFocus = popupTextfield
}
closeListeners.add {
stageToShowOn.keyboardFocus = null
Gdx.input.setOnscreenKeyboardVisible(false)
}
}
private fun clone(textField: TextField): TextField {
@Suppress("UNCIV_RAW_TEXTFIELD") // we are copying the existing text field
val copy = TextField(textField.text, textField.style)
copy.textFieldFilter = textField.textFieldFilter
copy.messageText = textField.messageText
copy.setSelection(textField.selectionStart, textField.selection.length)
copy.cursorPosition = textField.cursorPosition
copy.alignment = textField.alignment
copy.isPasswordMode = textField.isPasswordMode
copy.onscreenKeyboard = textField.onscreenKeyboard
return copy
}
}

View File

@ -1,11 +1,14 @@
package com.unciv.ui
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.utils.viewport.Viewport
import com.unciv.logic.event.Event
import com.unciv.logic.event.EventBus
import com.unciv.ui.crashhandling.wrapCrashHandling
import com.unciv.ui.crashhandling.wrapCrashHandlingUnit
import com.unciv.utils.Log
/** Main stage for the game. Catches all exceptions or errors thrown by event handlers, calling [com.unciv.UncivGame.handleUncaughtThrowable] with the thrown exception or error. */
@ -17,6 +20,23 @@ class UncivStage(viewport: Viewport) : Stage(viewport) {
*/
var performPointerEnterExitEvents: Boolean = true
var lastKnownVisibleArea: Rectangle
private set
private val events = EventBus.EventReceiver()
init {
lastKnownVisibleArea = Rectangle(0f, 0f, width, height)
events.receive(VisibleAreaChanged::class) {
Log.debug("Visible stage area changed: %s", it.visibleArea)
lastKnownVisibleArea = it.visibleArea
}
}
override fun dispose() {
events.stopReceiving()
super.dispose()
}
override fun draw() =
{ super.draw() }.wrapCrashHandlingUnit()()
@ -60,4 +80,7 @@ class UncivStage(viewport: Viewport) : Stage(viewport) {
override fun keyTyped(character: Char) =
{ super.keyTyped(character) }.wrapCrashHandling()() ?: true
class VisibleAreaChanged(
val visibleArea: Rectangle
) : Event
}

View File

@ -9,13 +9,14 @@ import com.unciv.logic.MapSaver
import com.unciv.logic.map.MapType
import com.unciv.logic.map.TileMap
import com.unciv.models.translations.tr
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.utils.AutoScrollPane
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.TabbedPager
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.isEnabled
import com.unciv.ui.utils.extensions.keyShortcuts
import com.unciv.ui.utils.extensions.onActivation
@ -37,7 +38,7 @@ class MapEditorSaveTab(
private val deleteButton = "Delete map".toTextButton()
private val quitButton = "Exit map editor".toTextButton()
private val mapNameTextField = TextField("", skin)
private val mapNameTextField = UncivTextField.create("Map Name")
private var chosenMap: FileHandle? = null

View File

@ -2,13 +2,13 @@ package com.unciv.ui.multiplayer
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.UncivGame
import com.unciv.logic.IdChecker
import com.unciv.logic.multiplayer.FriendList
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.enable
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.toLabel
@ -17,13 +17,12 @@ import java.util.*
class AddFriendScreen : PickerScreen() {
init {
val friendNameTextField = TextField("", skin)
val friendNameTextField = UncivTextField.create("Please input a name for your friend!")
val pastePlayerIDButton = "Paste player ID from clipboard".toTextButton()
val playerIDTextField = TextField("", skin)
val playerIDTextField = UncivTextField.create("Please input a player ID for your friend!")
val friendlist = FriendList()
topTable.add("Friend name".toLabel()).row()
friendNameTextField.messageText = "Please input a name for your friend!".tr()
topTable.add(friendNameTextField).pad(10f).padBottom(30f).width(stage.width/2).row()
pastePlayerIDButton.onClick {
@ -32,7 +31,6 @@ class AddFriendScreen : PickerScreen() {
topTable.add("Player ID".toLabel()).row()
val gameIDTable = Table()
playerIDTextField.messageText = "Please input a player ID for your friend!".tr()
gameIDTable.add(playerIDTextField).pad(10f).width(2*stage.width/3 - pastePlayerIDButton.width)
gameIDTable.add(pastePlayerIDButton)
topTable.add(gameIDTable).padBottom(30f).row()

View File

@ -2,12 +2,12 @@ package com.unciv.ui.multiplayer
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.logic.IdChecker
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.enable
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.toLabel
@ -18,8 +18,8 @@ import java.util.*
class AddMultiplayerGameScreen : PickerScreen() {
init {
val gameNameTextField = TextField("", skin)
val gameIDTextField = TextField("", skin)
val gameNameTextField = UncivTextField.create("Game name")
val gameIDTextField = UncivTextField.create("GameID")
val pasteGameIDButton = "Paste gameID from clipboard".toTextButton()
pasteGameIDButton.onClick {
gameIDTextField.text = Gdx.app.clipboard.contents

View File

@ -3,7 +3,6 @@ package com.unciv.ui.multiplayer
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.UncivGame
import com.unciv.logic.IdChecker
import com.unciv.logic.multiplayer.FriendList
@ -11,6 +10,7 @@ import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.enable
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.toLabel
@ -19,14 +19,13 @@ import java.util.*
class EditFriendScreen(selectedFriend: FriendList.Friend) : PickerScreen() {
init {
val friendNameTextField = TextField(selectedFriend.name, skin)
val friendNameTextField = UncivTextField.create("Please input a name for your friend!", selectedFriend.name)
val pastePlayerIDButton = "Player ID from clipboard".toTextButton()
val playerIDTextField = TextField(selectedFriend.playerID, skin)
val playerIDTextField = UncivTextField.create("Please input a player ID for your friend!", selectedFriend.playerID)
val deleteFriendButton = "Delete".toTextButton()
val friendlist = FriendList()
topTable.add("Friend name".toLabel()).row()
friendNameTextField.messageText = "Please input a name for your friend!".tr()
topTable.add(friendNameTextField).pad(10f).padBottom(30f).width(stage.width/2).row()
pastePlayerIDButton.onClick {
@ -35,7 +34,6 @@ class EditFriendScreen(selectedFriend: FriendList.Friend) : PickerScreen() {
topTable.add("Player ID".toLabel()).row()
val gameIDTable = Table()
playerIDTextField.messageText = "Please input a player ID for your friend!".tr()
gameIDTable.add(playerIDTextField).pad(10f).width(2*stage.width/3 - pastePlayerIDButton.width)
gameIDTable.add(pastePlayerIDButton)
topTable.add(gameIDTable).padBottom(30f).row()

View File

@ -2,13 +2,13 @@ package com.unciv.ui.multiplayer
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.disable
import com.unciv.ui.utils.extensions.enable
import com.unciv.ui.utils.extensions.onClick
@ -21,7 +21,7 @@ import com.unciv.utils.concurrency.launchOnGLThread
* backScreen is used for getting back to the MultiplayerScreen so it doesn't have to be created over and over again */
class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame) : PickerScreen() {
init {
val textField = TextField(multiplayerGame.name, skin)
val textField = UncivTextField.create("Game name", multiplayerGame.name)
topTable.add("Rename".toLabel()).row()
topTable.add(textField).pad(10f).padBottom(30f).width(stage.width / 2).row()
@ -31,8 +31,8 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame)
deleteButton.onClick {
val askPopup = ConfirmPopup(
this,
"Are you sure you want to delete this map?",
"Delete map",
"Are you sure you want to delete this save?",
"Delete save",
) {
try {
game.onlineMultiplayer.deleteGame(multiplayerGame)

View File

@ -4,7 +4,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.*
import com.unciv.logic.multiplayer.FriendList
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.extensions.disable
import com.unciv.ui.utils.extensions.enable
import com.unciv.ui.utils.extensions.onClick

View File

@ -15,6 +15,7 @@ import com.unciv.logic.map.MapType
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.ExpanderTab
import com.unciv.ui.utils.UncivSlider
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.onChange
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.pad
@ -132,7 +133,7 @@ class MapParametersTable(
private fun addHexagonalSizeTable() {
val defaultRadius = mapParameters.mapSize.radius.toString()
customMapSizeRadius = TextField(defaultRadius, skin).apply {
customMapSizeRadius = UncivTextField.create("Radius", defaultRadius).apply {
textFieldFilter = DigitsOnlyFilter()
}
customMapSizeRadius.onChange {
@ -146,12 +147,12 @@ class MapParametersTable(
private fun addRectangularSizeTable() {
val defaultWidth = mapParameters.mapSize.width.toString()
customMapWidth = TextField(defaultWidth, skin).apply {
customMapWidth = UncivTextField.create("Width", defaultWidth).apply {
textFieldFilter = DigitsOnlyFilter()
}
val defaultHeight = mapParameters.mapSize.height.toString()
customMapHeight = TextField(defaultHeight, skin).apply {
customMapHeight = UncivTextField.create("Height", defaultHeight).apply {
textFieldFilter = DigitsOnlyFilter()
}
@ -252,7 +253,7 @@ class MapParametersTable(
private fun addAdvancedControls(table: Table) {
table.defaults().pad(5f)
seedTextField = TextField(mapParameters.seed.toString(), skin)
seedTextField = UncivTextField.create("RNG Seed", mapParameters.seed.toString())
seedTextField.textFieldFilter = DigitsOnlyFilter()
// If the field is empty, fallback seed value to 0

View File

@ -6,12 +6,12 @@ import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.ImageButton
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.IdChecker
import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.multiplayer.FriendList
import com.unciv.models.metadata.GameParameters
import com.unciv.models.metadata.Player
import com.unciv.models.ruleset.Nation
@ -21,7 +21,6 @@ import com.unciv.ui.audio.MusicMood
import com.unciv.ui.audio.MusicTrackChooserFlags
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.mapeditor.GameParametersScreen
import com.unciv.logic.multiplayer.FriendList
import com.unciv.ui.multiplayer.FriendPickerList
import com.unciv.ui.pickerscreens.PickerPane
import com.unciv.ui.pickerscreens.PickerScreen
@ -165,8 +164,7 @@ class PlayerPickerTable(
}
if (gameParameters.isOnlineMultiplayer && player.playerType == PlayerType.Human) {
val playerIdTextField = TextField(player.playerId, BaseScreen.skin)
playerIdTextField.messageText = "Please input Player ID!".tr()
val playerIdTextField = UncivTextField.create("Please input Player ID!", player.playerId)
playerTable.add(playerIdTextField).colspan(2).fillX().pad(5f)
val errorLabel = "".toLabel(Color.RED)
playerTable.add(errorLabel).pad(5f).row()

View File

@ -1,7 +1,6 @@
package com.unciv.ui.options
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.UncivGame
import com.unciv.logic.GameSaver
import com.unciv.logic.MapSaver
@ -9,6 +8,7 @@ import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.UncivSlider
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.toCheckBox
import com.unciv.ui.utils.extensions.toLabel
@ -22,7 +22,7 @@ fun debugTab() = Table(BaseScreen.skin).apply {
val worldScreen = game.worldScreen
if (worldScreen != null) {
val simulateButton = "Simulate until turn:".toTextButton()
val simulateTextField = TextField(game.simulateUntilTurnForDebug.toString(), BaseScreen.skin)
val simulateTextField = UncivTextField.create("Turn", game.simulateUntilTurnForDebug.toString())
val invalidInputLabel = "This is not a valid integer!".toLabel().also { it.isVisible = false }
simulateButton.onClick {
val simulateUntilTurns = simulateTextField.text.toIntOrNull()

View File

@ -15,6 +15,7 @@ import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.addSeparator
import com.unciv.ui.utils.extensions.brighten
import com.unciv.ui.utils.extensions.format
@ -131,7 +132,7 @@ private fun addMultiplayerServerOptions(
} else {
"https://"
}
val multiplayerServerTextField = TextField(textToShowForMultiplayerAddress, BaseScreen.skin)
val multiplayerServerTextField = UncivTextField.create("Server address", textToShowForMultiplayerAddress)
multiplayerServerTextField.setTextFieldFilter { _, c -> c !in " \r\n\t\\" }
multiplayerServerTextField.programmaticChangeEvents = true
val serverIpTable = Table()

View File

@ -30,7 +30,6 @@ import com.unciv.ui.utils.extensions.toGdxArray
import com.unciv.ui.utils.extensions.toLabel
import com.unciv.ui.worldscreen.WorldScreen
import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.Dispatcher
import com.unciv.utils.concurrency.withGLContext
import kotlin.reflect.KMutableProperty0
@ -43,7 +42,7 @@ class OptionsPopup(
screen: BaseScreen,
private val selectPage: Int = defaultPage,
private val onClose: () -> Unit = {}
) : Popup(screen) {
) : Popup(screen.stage, /** [TabbedPager] handles scrolling */ scrollable = false ) {
val settings = screen.game.settings
val tabs: TabbedPager
val selectBoxMinWidth: Float

View File

@ -5,7 +5,6 @@ import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.models.ruleset.Ruleset
@ -15,6 +14,7 @@ import com.unciv.ui.newgamescreen.TranslatedSelectBox
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.ExpanderTab
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.utils.extensions.keyShortcuts
import com.unciv.ui.utils.extensions.onActivation
@ -72,7 +72,7 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
}
}
private val textField = TextField("", BaseScreen.skin)
private val textField = UncivTextField.create("Enter search text")
fun getFilterText(): String = textField.text
var sortInstalled = SortType.Name
@ -85,8 +85,6 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
val expander: ExpanderTab
init {
textField.messageText = "Enter search text".tr()
val searchIcon = ImageGetter.getImage("OtherIcons/Search")
.surroundWithCircle(50f, color = Color.CLEAR)

View File

@ -8,7 +8,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextArea
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.MainMenuScreen
@ -20,22 +19,23 @@ import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.pickerscreens.ModManagementOptions.SortType
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.utils.AutoScrollPane
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.ExpanderTab
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.RecreateOnResize
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.WrappableLabel
import com.unciv.ui.utils.extensions.UncivDateFormat.formatDate
import com.unciv.ui.utils.extensions.UncivDateFormat.parseDate
import com.unciv.ui.utils.extensions.addSeparator
import com.unciv.ui.utils.extensions.disable
import com.unciv.ui.utils.extensions.enable
import com.unciv.ui.utils.extensions.keyShortcuts
import com.unciv.ui.utils.extensions.isEnabled
import com.unciv.ui.utils.extensions.keyShortcuts
import com.unciv.ui.utils.extensions.onActivation
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.toCheckBox
@ -370,13 +370,13 @@ class ModManagementScreen(
downloadButton.onClick {
val popup = Popup(this)
popup.addGoodSizedLabel("Please enter the mod repository -or- archive zip url:").row()
val textArea = TextArea("https://github.com/...", skin)
popup.add(textArea).width(stage.width / 2).row()
val textField = UncivTextField.create("")
popup.add(textField).width(stage.width / 2).row()
val actualDownloadButton = "Download".toTextButton()
actualDownloadButton.onClick {
actualDownloadButton.setText("Downloading...".tr())
actualDownloadButton.disable()
downloadMod(Github.Repo().parseUrl(textArea.text)) { popup.close() }
downloadMod(Github.Repo().parseUrl(textField.text)) { popup.close() }
}
popup.add(actualDownloadButton).row()
popup.addCloseButton()
@ -541,13 +541,12 @@ class ModManagementScreen(
screen = this,
question = "Are you SURE you want to delete this mod?",
confirmText = deleteText,
action = {
restoreDefault = { rightSideButton.isEnabled = true }
) {
deleteMod(mod.ruleset)
modActionTable.clear()
rightSideButton.setText("[${mod.name}] was deleted.".tr())
},
restoreDefault = { rightSideButton.isEnabled = true }
).open()
}.open()
}
}

View File

@ -7,6 +7,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.onChange
import com.unciv.ui.utils.extensions.onClick
import com.unciv.ui.utils.extensions.surroundWithCircle
@ -60,7 +61,7 @@ class AskNumberPopup(
wrapper.add(label.toLabel())
add(wrapper).colspan(2).row()
val nameField = TextField(defaultValue, skin)
val nameField = UncivTextField.create(label, defaultValue)
nameField.textFieldFilter = TextField.TextFieldFilter { _, char -> char.isDigit() || char == '-' }
fun isValidInt(input: String): Boolean {

View File

@ -6,6 +6,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.extensions.surroundWithCircle
import com.unciv.ui.utils.extensions.toLabel
@ -39,7 +40,7 @@ class AskTextPopup(
wrapper.add(label.toLabel())
add(wrapper).colspan(2).row()
val nameField = TextField(defaultText, skin)
val nameField = UncivTextField.create(label, defaultText)
nameField.textFieldFilter = TextField.TextFieldFilter { _, char -> char !in illegalChars}
nameField.maxLength = maxLength

View File

@ -1,6 +1,7 @@
package com.unciv.ui.popup
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.scenes.scene2d.Touchable
@ -13,6 +14,8 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.logic.event.EventBus
import com.unciv.ui.UncivStage
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.AutoScrollPane
import com.unciv.ui.utils.BaseScreen
@ -30,7 +33,8 @@ import com.unciv.ui.utils.extensions.toTextButton
*/
@Suppress("MemberVisibilityCanBePrivate")
open class Popup(
val stageToShowOn: Stage
val stageToShowOn: Stage,
scrollable: Boolean = true
): Table(BaseScreen.skin) {
constructor(screen: BaseScreen) : this(screen.stage)
@ -39,22 +43,22 @@ open class Popup(
// from the 'screen blocking' part of the popup (which covers the entire screen)
val innerTable = Table(BaseScreen.skin)
val showListeners = mutableListOf<() -> Unit>()
val closeListeners = mutableListOf<() -> Unit>()
val scrollPane: AutoScrollPane
val events = EventBus.EventReceiver()
init {
// Set actor name for debugging
name = javaClass.simpleName
scrollPane = AutoScrollPane(innerTable, BaseScreen.skin)
background = ImageGetter.getBackground(Color.GRAY.cpy().apply { a=.5f })
innerTable.background = ImageGetter.getBackground(ImageGetter.getBlue().darken(0.5f))
innerTable.pad(20f)
innerTable.defaults().pad(5f)
super.add(scrollPane)
super.add(if (scrollable) AutoScrollPane(innerTable, BaseScreen.skin) else innerTable)
this.isVisible = false
touchable = Touchable.enabled // don't allow clicking behind
@ -70,20 +74,34 @@ open class Popup(
innerTable.pack()
pack()
center(stageToShowOn)
events.receive(UncivStage.VisibleAreaChanged::class) {
fitContentIntoVisibleArea(it.visibleArea)
}
fitContentIntoVisibleArea((stageToShowOn as UncivStage).lastKnownVisibleArea)
if (force || !stageToShowOn.hasOpenPopups()) {
show()
}
}
private fun fitContentIntoVisibleArea(visibleArea: Rectangle) {
padLeft(visibleArea.x)
padBottom(visibleArea.y)
padRight(stageToShowOn.width - visibleArea.x - visibleArea.width)
padTop(stageToShowOn.height - visibleArea.y - visibleArea.height)
invalidate()
}
/** Subroutine for [open] handles only visibility */
private fun show() {
this.isVisible = true
for (listener in showListeners) listener()
}
/**
* Close this popup and - if any other popups are pending - display the next one.
*/
open fun close() {
events.stopReceiving()
for (listener in closeListeners) listener()
remove()
val nextPopup = stageToShowOn.actors.firstOrNull { it is Popup }

View File

@ -4,14 +4,14 @@ import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.logic.GameSaver
import com.unciv.models.translations.tr
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.ConfirmPopup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.UncivTextField
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.utils.extensions.disable
import com.unciv.ui.utils.extensions.enable
@ -25,7 +25,7 @@ import com.unciv.utils.concurrency.launchOnGLThread
class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") {
private val gameNameTextField = TextField("", skin)
private val gameNameTextField = UncivTextField.create("Saved game name")
init {
setDefaultCloseAction()

View File

@ -1,9 +1,7 @@
package com.unciv.ui.utils
import com.badlogic.gdx.Gdx
import com.unciv.UncivGame
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.models.metadata.GameSettings
import com.unciv.ui.crashhandling.CrashScreen
/** Interface to support various platform-specific tools */
interface GeneralPlatformSpecificHelpers {
@ -35,4 +33,10 @@ interface GeneralPlatformSpecificHelpers {
* @return true if the throwable was handled.
*/
fun handleUncaughtThrowable(ex: Throwable): Boolean = false
/**
* Adds platform-specific improvements to the given text field, making it nicer to interact with on this platform.
*/
fun addImprovements(textField: TextField): TextField = textField
}

View File

@ -1,6 +1,5 @@
package com.unciv.ui.utils
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.EventListener
@ -12,7 +11,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.scenes.scene2d.utils.ActorGestureListener
import com.badlogic.gdx.utils.Align
@ -22,10 +20,10 @@ import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.utils.extensions.keyShortcuts
import com.unciv.ui.utils.extensions.addSeparator
import com.unciv.ui.utils.extensions.darken
import com.unciv.ui.utils.extensions.isEnabled
import com.unciv.ui.utils.extensions.keyShortcuts
import com.unciv.ui.utils.extensions.onActivation
import com.unciv.ui.utils.extensions.packIfNeeded
import com.unciv.ui.utils.extensions.pad
@ -328,7 +326,7 @@ open class TabbedPager(
}
override fun getMinWidth() = dimW.min
override fun getMaxWidth() = dimW.max
override fun getMinHeight() = dimH.min + headerHeight
override fun getMinHeight() = headerHeight
override fun getMaxHeight() = dimH.max + headerHeight
//endregion
@ -574,7 +572,7 @@ open class TabbedPager(
*/
fun askForPassword(secretHashCode: Int = 0) {
class PassPopup(screen: BaseScreen, unlockAction: ()->Unit, lockAction: ()->Unit) : Popup(screen) {
val passEntry = TextField("", BaseScreen.skin)
val passEntry = UncivTextField.create("Password")
init {
passEntry.isPasswordMode = true
add(passEntry).row()

View File

@ -0,0 +1,97 @@
package com.unciv.ui.utils
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor
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.UncivGame
import com.unciv.models.translations.tr
import com.unciv.ui.UncivStage
import com.unciv.ui.utils.extensions.getAscendant
import com.unciv.ui.utils.extensions.getOverlap
import com.unciv.ui.utils.extensions.right
import com.unciv.ui.utils.extensions.stageBoundingBox
import com.unciv.ui.utils.extensions.top
object UncivTextField {
/**
* Creates a text field that has nicer platform-specific input added compared to the default gdx [TextField].
* @param hint The text that should be displayed in the text field when no text is entered, will automatically be translated
* @param preEnteredText the text already entered within this text field. Supported on all platforms.
*/
fun create(hint: String, preEnteredText: String = ""): TextField {
@Suppress("UNCIV_RAW_TEXTFIELD")
val textField = TextField(preEnteredText, BaseScreen.skin)
val translatedHint = hint.tr()
textField.messageText = translatedHint
textField.addListener(object : FocusListener() {
override fun keyboardFocusChanged(event: FocusEvent, actor: Actor, focused: Boolean) {
if (focused) {
textField.scrollAscendantToTextField()
}
}
})
UncivGame.Current.platformSpecificHelper?.addImprovements(textField)
return textField
}
}
/**
* Tries to scroll a [ScrollPane] ascendant of the text field so that this text field is in the middle of the visible area.
*
* @return true if the text field is visible after this operation
*/
fun TextField.scrollAscendantToTextField(): Boolean {
val stage = this.stage
if (stage !is UncivStage) return false
val scrollPane = this.getAscendant { it is ScrollPane } as ScrollPane?
val visibleArea = stage.lastKnownVisibleArea
val textFieldStageBoundingBox = this.stageBoundingBox
if (scrollPane == null) return visibleArea.contains(textFieldStageBoundingBox)
val scrollPaneBounds = scrollPane.stageBoundingBox
val visibleScrollPaneArea = scrollPaneBounds.getOverlap(visibleArea)
if (visibleScrollPaneArea == null) {
return false
} else if (visibleScrollPaneArea.contains(textFieldStageBoundingBox)) {
return true
}
val scrollContent = scrollPane.actor
val textFieldScrollContentCoords = localToAscendantCoordinates(scrollContent, Vector2(0f, 0f))
// It's possible that our textField can't be (fully) scrolled to be within the visible scrollPane area
val pixelsNotVisibleOnLeftSide = (visibleScrollPaneArea.x - scrollPaneBounds.x).coerceAtLeast(0f)
val textFieldDistanceFromLeftSide = textFieldScrollContentCoords.x
val pixelsNotVisibleOnRightSide = (scrollPaneBounds.right - visibleScrollPaneArea.right).coerceAtLeast(0f)
val textFieldDistanceFromRightSide = scrollContent.width - (textFieldScrollContentCoords.x + this.width)
val pixelsNotVisibleOnTop = (scrollPaneBounds.top - visibleScrollPaneArea.top).coerceAtLeast(0f)
val textFieldDistanceFromTop = scrollContent.height - (textFieldScrollContentCoords.y + this.height)
val pixelsNotVisibleOnBottom = (visibleScrollPaneArea.y - scrollPaneBounds.y).coerceAtLeast(0f)
val textFieldDistanceFromBottom = textFieldScrollContentCoords.y
// If the visible scroll pane area is smaller than our text field, it will always be partly obscured
if (visibleScrollPaneArea.width < this.width || visibleScrollPaneArea.height < this.height
// If the amount of pixels obscured near a scrollContent edge is larger than the distance of the text field to that edge, it will always be (partly) obscured
|| pixelsNotVisibleOnLeftSide > textFieldDistanceFromLeftSide
|| pixelsNotVisibleOnRightSide > textFieldDistanceFromRightSide
|| pixelsNotVisibleOnTop > textFieldDistanceFromTop
|| pixelsNotVisibleOnBottom > textFieldDistanceFromBottom) {
return false
}
// We want to put the text field in the middle of the visible area
val scrollXMiddle = textFieldScrollContentCoords.x - this.width / 2 + visibleScrollPaneArea.width / 2
// If the visible area is to the right of the left edge of the scroll pane, we need to scroll that much farther to get to the real visible middle
scrollPane.scrollX = pixelsNotVisibleOnLeftSide + scrollXMiddle
// ScrollPane.scrollY has the origin at the top instead of at the bottom, so + for height / 2 instead of -
// We want to put the text field in the middle of the visible area
val scrollYMiddleGdxOrigin = textFieldScrollContentCoords.y + this.height / 2 + visibleScrollPaneArea.height / 2
// If the visible area is below the top edge of the scroll pane, we need to scroll that much farther to get to the real visible middle
// Also, convert to scroll pane origin (0 is on top instead of bottom)
scrollPane.scrollY = pixelsNotVisibleOnTop + scrollContent.height - scrollYMiddleGdxOrigin
return true
}

View File

@ -3,6 +3,8 @@ package com.unciv.ui.utils.extensions
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.InputEvent
@ -31,10 +33,10 @@ import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.Fonts
import com.unciv.utils.concurrency.Concurrency
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.KeyShortcut
import com.unciv.ui.utils.KeyShortcutDispatcher
import com.unciv.utils.concurrency.Concurrency
/**
* Collection of extension functions mostly for libGdx widgets
@ -309,6 +311,55 @@ fun Actor.addBorder(size:Float, color: Color, expandCell:Boolean = false): Table
return table
}
/** Gets a parent of this actor that matches the [predicate], or null if none of its parents match the [predicate]. */
fun Actor.getAscendant(predicate: (Actor) -> Boolean): Actor? {
var curParent = parent
while (curParent != null) {
if (predicate(curParent)) return curParent
curParent = curParent.parent
}
return null
}
/** The actors bounding box in stage coordinates */
val Actor.stageBoundingBox: Rectangle get() {
val bottomleft = localToStageCoordinates(Vector2(0f, 0f))
val topright = localToStageCoordinates(Vector2(width, height))
return Rectangle(
bottomleft.x,
bottomleft.y,
topright.x - bottomleft.x,
topright.y - bottomleft.y
)
}
/** @return the area where this [Rectangle] overlaps with [other], or `null` if it doesn't overlap. */
fun Rectangle.getOverlap(other: Rectangle): Rectangle? {
val overlapX = if (x > other.x) x else other.x
val rightX = x + width
val otherRightX = other.x + other.width
val overlapWidth = (if (rightX < otherRightX) rightX else otherRightX) - overlapX
val overlapY = if (y > other.y) y else other.y
val topY = y + height
val otherTopY = other.y + other.height
val overlapHeight = (if (topY < otherTopY) topY else otherTopY) - overlapY
val noOverlap = overlapWidth <= 0 || overlapHeight <= 0
if (noOverlap) return null
return Rectangle(
overlapX,
overlapY,
overlapWidth,
overlapHeight
)
}
val Rectangle.top get() = y + height
val Rectangle.right get() = x + width
fun Group.addBorderAllowOpacity(size:Float, color: Color): Group {
val group = this
fun getTopBottomBorder() = ImageGetter.getDot(color).apply { width=group.width; height=size }