Custom save/load UI tweaks and blocking saving online MP games locally (#10358)

* Allow typing Y and N in file names in Linux custom save file dialog

* Fix default custom save location suggestion on X11

* Minor linting

* Allow clean reactivation of custom buttons after cancelling the file chooser

* User-Cancel support on Android and non-X11 desktop

* Block custom save for online multiplayer games

* Fix X11 custom save remembering location

* Fix X11 custom save "Save" button enabling, forbid potentially bad names

* Add overwrite confirmation to X11 custom save

* Redefine how local saving of online multiplayer games is blocked
This commit is contained in:
SomeTroglodyte 2023-10-29 18:00:45 +01:00 committed by GitHub
parent 74cfda9854
commit eb33b7d513
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 146 additions and 75 deletions

View File

@ -10,9 +10,6 @@ import com.unciv.utils.Log
import java.io.InputStream
import java.io.OutputStream
private class Request(
val onFileChosen: (Uri) -> Unit
)
class AndroidSaverLoader(private val activity: Activity) : PlatformSaverLoader {
@ -20,6 +17,13 @@ class AndroidSaverLoader(private val activity: Activity) : PlatformSaverLoader {
private val requests = HashMap<Int, Request>()
private var requestCode = 100
private class Request(
val onFileChosen: (Uri) -> Unit,
onError: (ex: Exception) -> Unit
) {
val onCancel: () -> Unit = { onError(PlatformSaverLoader.Cancelled()) }
}
override fun saveGame(
data: String,
suggestedLocation: String,
@ -31,7 +35,7 @@ class AndroidSaverLoader(private val activity: Activity) : PlatformSaverLoader {
val suggestedUri = Uri.parse(suggestedLocation)
val fileName = getFilename(suggestedUri, suggestedLocation)
val onFileChosen = {uri: Uri ->
val onFileChosen = { uri: Uri ->
var stream: OutputStream? = null
try {
stream = contentResolver.openOutputStream(uri, "rwt")
@ -44,14 +48,15 @@ class AndroidSaverLoader(private val activity: Activity) : PlatformSaverLoader {
}
}
requests[requestCode] = Request(onFileChosen)
requests[requestCode] = Request(onFileChosen, onError)
openSaveFileChooser(fileName, suggestedUri, requestCode)
requestCode += 1
}
override fun loadGame(
onLoaded: (data: String, location: String) -> Unit,
onError: (ex: Exception) -> Unit) {
onError: (ex: Exception) -> Unit
) {
val onFileChosen = {uri: Uri ->
var stream: InputStream? = null
@ -66,14 +71,16 @@ class AndroidSaverLoader(private val activity: Activity) : PlatformSaverLoader {
}
}
requests[requestCode] = Request(onFileChosen)
requests[requestCode] = Request(onFileChosen, onError)
openLoadFileChooser(requestCode)
requestCode += 1
}
fun onActivityResult(requestCode: Int, data: Intent?) {
val uri: Uri = data?.data ?: return
val request = requests.remove(requestCode) ?: return
// data is null if the user back out of the activity without choosing a file
if (data == null) return request.onCancel()
val uri: Uri = data.data ?: return
request.onFileChosen(uri)
}

View File

@ -16,22 +16,22 @@ import com.badlogic.gdx.scenes.scene2d.ui.Skin
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.badlogic.gdx.utils.Array as GdxArray
import com.unciv.Constants
import com.unciv.models.UncivSound
import com.unciv.models.translations.tr
import com.unciv.ui.components.widgets.AutoScrollPane
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.isEnabled
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.input.onDoubleClick
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.widgets.AutoScrollPane
import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.popups.Popup
import java.io.File
import java.io.FileFilter
import com.badlogic.gdx.utils.Array as GdxArray
typealias ResultListener = (success: Boolean, file: FileHandle) -> Unit
@ -125,7 +125,13 @@ open class FileChooser(
innerTable.top().left()
fileList.selection.setProgrammaticChangeEvents(false)
fileNameInput.setTextFieldListener { textField, _ -> result = textField.text }
fileNameInput.setTextFieldListener { textField, _ ->
result = textField.text
enableOKButton()
}
fileNameInput.setTextFieldFilter { _, char ->
char != File.separatorChar
}
if (title != null) {
addGoodSizedLabel(title).colspan(2).center().row()
@ -138,10 +144,10 @@ open class FileChooser(
fileNameCell = add().colspan(2).growX()
row()
addCloseButton("Cancel", KeyboardBinding.Cancel) {
addCloseButton("Cancel") {
reportResult(false)
}
okButton = addOKButton(Constants.OK, KeyboardBinding.Confirm) {
okButton = addOKButton(Constants.OK) {
reportResult(true)
}.actor
equalizeLastTwoButtonWidths()
@ -175,7 +181,18 @@ open class FileChooser(
override fun getMaxHeight() = maxHeight
private fun reportResult(success: Boolean) {
resultListener?.invoke(success, getResult())
val file = getResult()
if (!(success && fileNameEnabled && file.exists())) {
resultListener?.invoke(success, file)
return
}
ConfirmPopup(stageToShowOn, "Do you want to overwrite ${file.name()}?", "Overwrite",
restoreDefault = {
resultListener?.invoke(false, file)
}, action = {
resultListener?.invoke(true, file)
}
).open(true)
}
private fun makeAbsolute(file: FileHandle): FileHandle {
@ -200,7 +217,11 @@ open class FileChooser(
startFile == null ->
Gdx.files.absolute(absoluteLocalPath)
startFile.isDirectory -> startFile
else -> startFile.parent()
else -> {
fileNameInput.text = startFile.name()
result = startFile.name()
startFile.parent()
}
}))
}
@ -253,12 +274,17 @@ open class FileChooser(
}
private fun enableOKButton() {
fun getEnable(): Boolean {
fun getLoadEnable(): Boolean {
val file = fileList.selected?.file ?: return false
if (!file.exists()) return false
return (allowFolderSelect || !file.isDirectory)
}
okButton.isEnabled = getEnable()
fun getSaveEnable(): Boolean {
if (currentDir?.exists() != true) return false
if (allowFolderSelect) return true
return result?.run { isEmpty() || startsWith(' ') || endsWith(' ') } == false
}
okButton.isEnabled = if (fileNameEnabled) getSaveEnable() else getLoadEnable()
}
fun setOkButtonText(text: String) {

View File

@ -4,7 +4,14 @@ import com.badlogic.gdx.Gdx
import com.unciv.UncivGame
import com.unciv.utils.Concurrency
import java.awt.GraphicsEnvironment
import java.io.File
/**
* A dedicated PlatformSaverLoader for X11-based Linux boxes, as using the Java AWT/Swing file chooser dialog will kill the App after closing that dialog
*
* Tested as required from Mint 20.1 up to Mint 21.2, seems independent of Java runtime (mostly tested with adoptium temurin 11 and 17 versions).
*/
class LinuxX11SaverLoader : PlatformSaverLoader {
override fun saveGame(
data: String,
@ -13,15 +20,21 @@ class LinuxX11SaverLoader : PlatformSaverLoader {
onError: (ex: Exception) -> Unit
) {
Concurrency.runOnGLThread {
FileChooser.createSaveDialog(stage, "Save game", Gdx.files.absolute(suggestedLocation)) {
val startLocation =
if (suggestedLocation.startsWith(File.separator)) Gdx.files.absolute(suggestedLocation)
else if (Gdx.files.external(suggestedLocation).parent().exists()) Gdx.files.external(suggestedLocation)
else Gdx.files.local(suggestedLocation)
FileChooser.createSaveDialog(stage, "Save game", startLocation) {
success, file ->
if (!success) return@createSaveDialog
try {
file.writeString(data, false, Charsets.UTF_8.name())
onSaved(file.path())
} catch (ex: Exception) {
onError(ex)
}
if (!success)
onError(PlatformSaverLoader.Cancelled())
else
try {
file.writeString(data, false, Charsets.UTF_8.name())
onSaved(file.path())
} catch (ex: Exception) {
onError(ex)
}
}.open(true)
}
}
@ -32,13 +45,15 @@ class LinuxX11SaverLoader : PlatformSaverLoader {
) {
Concurrency.runOnGLThread {
FileChooser.createLoadDialog(stage, "Load game") { success, file ->
if (!success) return@createLoadDialog
try {
val data = file.readString(Charsets.UTF_8.name())
onLoaded(data, file.path())
} catch (ex: Exception) {
onError(ex)
}
if (!success)
onError(PlatformSaverLoader.Cancelled())
else
try {
val data = file.readString(Charsets.UTF_8.name())
onLoaded(data, file.path())
} catch (ex: Exception) {
onError(ex)
}
}.open(true)
}
}

View File

@ -21,6 +21,9 @@ interface PlatformSaverLoader {
onError: (Exception) -> Unit = {} // On-load-error callback
)
/** Invisible Exception can be used with onError callbacks to indicate the User cancelled the operation and needs no message */
class Cancelled : Exception()
companion object {
val None = object : PlatformSaverLoader {
override fun saveGame(

View File

@ -22,9 +22,9 @@ import com.unciv.ui.screens.savescreens.Gzip
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.debug
import kotlinx.coroutines.Job
import java.io.File
import java.io.Writer
import kotlinx.coroutines.Job
private const val SAVE_FILES_FOLDER = "SaveFiles"
private const val MULTIPLAYER_FILES_FOLDER = "MultiplayerGames"
@ -199,12 +199,13 @@ class UncivFiles(
game: GameInfo,
gameName: String,
onSaved: () -> Unit,
onError: (Exception) -> Unit) {
onError: (Exception) -> Unit
) {
val saveLocation = game.customSaveLocation ?: Gdx.files.local(gameName).path()
try {
val data = gameInfoToString(game)
debug("Saving GameInfo %s to custom location %s", game.gameId, saveLocation)
debug("Initiating UI to save GameInfo %s to custom location %s", game.gameId, saveLocation)
saverLoader.saveGame(data, saveLocation,
{ location ->
game.customSaveLocation = location

View File

@ -8,27 +8,28 @@ import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.SerializationException
import com.unciv.Constants
import com.unciv.logic.MissingModsException
import com.unciv.logic.files.UncivFiles
import com.unciv.logic.UncivShowableException
import com.unciv.logic.files.PlatformSaverLoader
import com.unciv.logic.files.UncivFiles
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.screens.pickerscreens.Github
import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.isEnabled
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.popups.LoadingPopup
import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.screens.pickerscreens.Github
import com.unciv.ui.screens.pickerscreens.Github.folderNameToRepoName
import com.unciv.utils.Log
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread
import java.io.FileNotFoundException
@ -170,25 +171,26 @@ class LoadGameScreen : LoadOrSaveScreen() {
}
private fun Table.addLoadFromCustomLocationButton() {
val loadFromCustomLocation = loadFromCustomLocation.toTextButton()
loadFromCustomLocation.onClick {
val loadFromCustomLocationButton = loadFromCustomLocation.toTextButton()
loadFromCustomLocationButton.onClick {
errorLabel.isVisible = false
loadFromCustomLocation.setText(Constants.loading.tr())
loadFromCustomLocation.disable()
loadFromCustomLocationButton.setText(Constants.loading.tr())
loadFromCustomLocationButton.disable()
Concurrency.run(Companion.loadFromCustomLocation) {
game.files.loadGameFromCustomLocation(
{
Concurrency.run { game.loadGame(it, true) }
loadFromCustomLocation.enable()
},
{
handleLoadGameException(it, "Could not load game from custom location!")
loadFromCustomLocation.enable()
if (it !is PlatformSaverLoader.Cancelled)
handleLoadGameException(it, "Could not load game from custom location!")
loadFromCustomLocationButton.setText(loadFromCustomLocation.tr())
loadFromCustomLocationButton.enable()
}
)
}
}
add(loadFromCustomLocation).row()
add(loadFromCustomLocationButton).row()
}
private fun getCopyExistingSaveToClipboardButton(): TextButton {

View File

@ -1,21 +1,24 @@
package com.unciv.ui.screens.savescreens
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.logic.UncivShowableException
import com.unciv.ui.popups.LoadingPopup
import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.utils.Concurrency
import com.unciv.utils.launchOnGLThread
import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread
//todo reduce code duplication
object QuickSave {
fun save(gameInfo: GameInfo, screen: WorldScreen) {
// See #10353 - we don't support locally saving an online multiplayer game
if (gameInfo.gameParameters.isOnlineMultiplayer) return
val files = UncivGame.Current.files
val toast = ToastPopup("Quicksaving...", screen)
Concurrency.runOnNonDaemonThreadPool("QuickSaveGame") {

View File

@ -6,18 +6,19 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.logic.files.PlatformSaverLoader
import com.unciv.logic.files.UncivFiles
import com.unciv.models.translations.tr
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.popups.ToastPopup
import com.unciv.utils.Concurrency
@ -25,7 +26,13 @@ import com.unciv.utils.launchOnGLThread
class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") {
private val gameNameTextField = UncivTextField.create("Saved game name")
companion object {
const val nameFieldLabelText = "Saved game name"
const val savingText = "Saving..."
const val saveToCustomText = "Save to custom location"
}
private val gameNameTextField = UncivTextField.create(nameFieldLabelText)
init {
setDefaultCloseAction()
@ -63,7 +70,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
gameNameTextField.text = defaultSaveName
gameNameTextField.setSelection(0, defaultSaveName.length)
add("Saved game name".toLabel()).row()
add(nameFieldLabelText.toLabel()).row()
add(gameNameTextField).width(300f).row()
}
@ -82,22 +89,24 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
}
private fun Table.addSaveToCustomLocation() {
val saveToCustomLocation = "Save to custom location".toTextButton()
val saveToCustomLocation = saveToCustomText.toTextButton()
val errorLabel = "".toLabel(Color.RED)
saveToCustomLocation.onClick {
errorLabel.setText("")
saveToCustomLocation.setText("Saving...".tr())
saveToCustomLocation.setText(savingText.tr())
saveToCustomLocation.disable()
Concurrency.runOnNonDaemonThreadPool("Save to custom location") {
Concurrency.runOnNonDaemonThreadPool(saveToCustomText) {
game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text,
{
game.popScreen()
saveToCustomLocation.enable()
},
{
errorLabel.setText("Could not save game to custom location!".tr())
it.printStackTrace()
if (it !is PlatformSaverLoader.Cancelled) {
errorLabel.setText("Could not save game to custom location!".tr())
it.printStackTrace()
}
saveToCustomLocation.setText(saveToCustomText.tr())
saveToCustomLocation.enable()
}
)
@ -108,7 +117,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
}
private fun saveGame() {
rightSideButton.setText("Saving...".tr())
rightSideButton.setText(savingText.tr())
Concurrency.runOnNonDaemonThreadPool("SaveGame") {
game.files.saveGame(gameInfo, gameNameTextField.text) {
launchOnGLThread {

View File

@ -233,6 +233,12 @@ class WorldScreen(
game.pushScreen(newGameScreen)
}
fun openSaveGameScreen() {
// See #10353 - we don't support locally saving an online multiplayer game
if (gameInfo.gameParameters.isOnlineMultiplayer) return
game.pushScreen(SaveGameScreen(gameInfo))
}
private fun addKeyboardPresses() {
// Space and N are assigned in NextTurnButton constructor
// Functions that have a big button are assigned there (WorldScreenTopBar, TechPolicyDiplomacyButtons..)
@ -254,7 +260,7 @@ class WorldScreen(
globalShortcuts.add(KeyboardBinding.Options) { // Game Options
openOptionsPopup { nextTurnButton.update() }
}
globalShortcuts.add(KeyboardBinding.SaveGame) { game.pushScreen(SaveGameScreen(gameInfo)) } // Save
globalShortcuts.add(KeyboardBinding.SaveGame) { openSaveGameScreen() } // Save
globalShortcuts.add(KeyboardBinding.LoadGame) { game.pushScreen(LoadGameScreen()) } // Load
globalShortcuts.add(KeyboardBinding.QuitGame) { game.popScreen() } // WorldScreen is the last screen, so this quits
globalShortcuts.add(KeyboardBinding.NewGame) { openNewGameScreen() }

View File

@ -4,7 +4,6 @@ import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
import com.unciv.ui.screens.savescreens.LoadGameScreen
import com.unciv.ui.screens.savescreens.SaveGameScreen
import com.unciv.ui.screens.victoryscreen.VictoryScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
@ -19,10 +18,11 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen, sc
close()
worldScreen.game.pushScreen(CivilopediaScreen(worldScreen.gameInfo.ruleset))
}.row()
addButton("Save game", KeyboardBinding.SaveGame) {
close()
worldScreen.game.pushScreen(SaveGameScreen(worldScreen.gameInfo))
}.row()
if (!worldScreen.gameInfo.gameParameters.isOnlineMultiplayer)
addButton("Save game", KeyboardBinding.SaveGame) {
close()
worldScreen.openSaveGameScreen()
}.row()
addButton("Load game", KeyboardBinding.LoadGame) {
close()
worldScreen.game.pushScreen(LoadGameScreen())

View File

@ -2,7 +2,6 @@ package com.unciv.app.desktop
import com.badlogic.gdx.Gdx
import com.unciv.logic.files.PlatformSaverLoader
import com.unciv.utils.Log
import java.awt.Component
import java.awt.EventQueue
import java.awt.event.WindowEvent
@ -76,7 +75,7 @@ class DesktopSaverLoader : PlatformSaverLoader {
frame.dispose()
if (result == JFileChooser.CANCEL_OPTION) {
return@invokeLater
onError(PlatformSaverLoader.Cancelled())
} else {
val value = createValue(fileChooser.selectedFile)
onSuccess(value, fileChooser.selectedFile.absolutePath)