mirror of
https://github.com/yairm210/Unciv.git
synced 2025-08-03 12:37:42 -04:00
Improve save file name and errors handling (#13164)
* Centralize filters for allowed file names * Improve handling of load/save file errors * More improvements and reuse of common elements in load/save
This commit is contained in:
parent
269335efa7
commit
a26e71b1fa
@ -758,6 +758,7 @@ Saved at =
|
||||
Saving... =
|
||||
Overwrite existing file? =
|
||||
Overwrite =
|
||||
The file is marked read-only. =
|
||||
It looks like your saved game can't be loaded! =
|
||||
If you could copy your game data ("Copy saved game to clipboard" - =
|
||||
paste into an email to yairm210@hotmail.com) =
|
||||
@ -767,6 +768,7 @@ Load from custom location =
|
||||
Save to custom location =
|
||||
Could not save game to custom location! =
|
||||
'[saveFileName]' copied to clipboard! =
|
||||
Current game copied to clipboard! =
|
||||
Could not save game to clipboard! =
|
||||
Download missing mods =
|
||||
Missing mods are downloaded successfully. =
|
||||
|
@ -130,9 +130,7 @@ open class FileChooser(
|
||||
result = textField.text
|
||||
enableOKButton()
|
||||
}
|
||||
fileNameInput.setTextFieldFilter { _, char ->
|
||||
char != File.separatorChar
|
||||
}
|
||||
fileNameInput.textFieldFilter = UncivFiles.fileNameTextFieldFilter()
|
||||
|
||||
if (title != null) {
|
||||
addGoodSizedLabel(title).colspan(2).center().row()
|
||||
@ -283,7 +281,7 @@ open class FileChooser(
|
||||
fun getSaveEnable(): Boolean {
|
||||
if (currentDir?.exists() != true) return false
|
||||
if (allowFolderSelect) return true
|
||||
return result?.run { isEmpty() || startsWith(' ') || endsWith(' ') } == false
|
||||
return result != null && UncivFiles.isValidFileName(result!!)
|
||||
}
|
||||
okButton.isEnabled = if (fileNameEnabled) getSaveEnable() else getLoadEnable()
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package com.unciv.logic.files
|
||||
import com.badlogic.gdx.Files
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextField
|
||||
import com.badlogic.gdx.utils.GdxRuntimeException
|
||||
import com.badlogic.gdx.utils.JsonReader
|
||||
import com.badlogic.gdx.utils.SerializationException
|
||||
@ -443,6 +444,25 @@ class UncivFiles(
|
||||
return Gzip.zip(json().toJson(game))
|
||||
}
|
||||
|
||||
private val charsForbiddenInFileNames = setOf('\\', '/', ':')
|
||||
private val _fileNameTextFieldFilter = TextField.TextFieldFilter { _, char ->
|
||||
char !in charsForbiddenInFileNames
|
||||
}
|
||||
/** Check characters typed into a file name TextField: Disallows both Unix and Windows path separators, plus the
|
||||
* ['NTFS alternate streams'](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/b134f29a-6278-4f3f-904f-5e58a713d2c5)
|
||||
* indicator, irrespective of platform, in case players wish to exchange files cross-platform.
|
||||
* @see isValidFileName
|
||||
* @return A `TextFieldFilter` appropriate for `TextField`s used to enter a file name for saving
|
||||
*/
|
||||
fun fileNameTextFieldFilter() = _fileNameTextFieldFilter
|
||||
|
||||
/** Determines whether a filename is acceptable.
|
||||
* - Forbids trailing blanks because Windows has trouble with them.
|
||||
* - Forbids leading blanks because they might confuse users (neither Windows nor Linux have noteworthy problems with them).
|
||||
* - Does **not** deal with problems that can be recognized inspecting a single character, use [fileNameTextFieldFilter] for that.
|
||||
* @param fileName A base file name, not a path.
|
||||
*/
|
||||
fun isValidFileName(fileName: String) = fileName.isNotEmpty() && !fileName.endsWith(' ') && !fileName.startsWith(' ')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.unciv.Constants
|
||||
import com.unciv.logic.files.UncivFiles
|
||||
import com.unciv.logic.multiplayer.MultiplayerGame
|
||||
import com.unciv.logic.multiplayer.storage.MultiplayerAuthException
|
||||
import com.unciv.models.ruleset.RulesetCache
|
||||
@ -290,7 +291,7 @@ class MultiplayerScreen : PickerScreen() {
|
||||
Popup(this).apply {
|
||||
val textField = UncivTextField("Game name", selectedGame!!.name)
|
||||
// slashes in mp names are interpreted as directory separators, so we don't allow them
|
||||
textField.setTextFieldFilter { _, c -> c != '/' && c != '\\' }
|
||||
textField.textFieldFilter = UncivFiles.fileNameTextFieldFilter()
|
||||
add(textField).width(stageToShowOn.width / 2).row()
|
||||
val saveButton = "Save".toTextButton()
|
||||
|
||||
|
@ -2,25 +2,19 @@ package com.unciv.ui.screens.savescreens
|
||||
|
||||
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.TextButton
|
||||
import com.badlogic.gdx.utils.SerializationException
|
||||
import com.unciv.Constants
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.MissingModsException
|
||||
import com.unciv.logic.UncivShowableException
|
||||
import com.unciv.logic.files.PlatformSaverLoader
|
||||
import com.unciv.logic.files.UncivFiles
|
||||
import com.unciv.logic.github.Github
|
||||
import com.unciv.logic.github.Github.folderNameToRepoName
|
||||
import com.unciv.models.ruleset.RulesetCache
|
||||
import com.unciv.models.translations.tr
|
||||
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
|
||||
@ -32,71 +26,22 @@ import com.unciv.ui.popups.ToastPopup
|
||||
import com.unciv.utils.Concurrency
|
||||
import com.unciv.utils.Log
|
||||
import com.unciv.utils.launchOnGLThread
|
||||
import java.io.FileNotFoundException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class LoadGameScreen : LoadOrSaveScreen() {
|
||||
private val copySavedGameToClipboardButton = getCopyExistingSaveToClipboardButton()
|
||||
private val errorLabel = "".toLabel(Color.RED)
|
||||
private val loadMissingModsButton = getLoadMissingModsButton()
|
||||
private var missingModsToLoad: Iterable<String> = emptyList()
|
||||
|
||||
companion object {
|
||||
/** Inheriting here again exposes [getLoadExceptionMessage] and [loadMissingMods] to
|
||||
* other clients (WorldScreen, QuickSave, Multiplayer) without needing to rewrite many imports
|
||||
*/
|
||||
companion object : Helpers {
|
||||
private const val loadGame = "Load game"
|
||||
private const val loadFromCustomLocation = "Load from custom location"
|
||||
private const val loadFromClipboard = "Load copied data"
|
||||
private const val copyExistingSaveToClipboard = "Copy saved game to clipboard"
|
||||
internal const val downloadMissingMods = "Download missing mods"
|
||||
|
||||
/** Gets a translated exception message to show to the user.
|
||||
* @return The first returned value is the message, the second is signifying if the user can likely fix this problem. */
|
||||
fun getLoadExceptionMessage(ex: Throwable, primaryText: String = "Could not load game!"): Pair<String, Boolean> {
|
||||
val errorText = StringBuilder(primaryText.tr())
|
||||
|
||||
val isUserFixable: Boolean
|
||||
errorText.appendLine()
|
||||
when (ex) {
|
||||
is UncivShowableException -> {
|
||||
errorText.append(ex.localizedMessage)
|
||||
isUserFixable = true
|
||||
}
|
||||
is SerializationException -> {
|
||||
errorText.append("The file data seems to be corrupted.".tr())
|
||||
isUserFixable = false
|
||||
}
|
||||
is FileNotFoundException -> {
|
||||
isUserFixable = if (ex.cause?.message?.contains("Permission denied") == true) {
|
||||
errorText.append("You do not have sufficient permissions to access the file.".tr())
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
errorText.append("Unhandled problem, [${ex::class.simpleName} ${ex.stackTraceToString()}]".tr())
|
||||
isUserFixable = false
|
||||
}
|
||||
}
|
||||
return Pair(errorText.toString(), isUserFixable)
|
||||
}
|
||||
|
||||
fun loadMissingMods(missingMods: Iterable<String>, onModDownloaded:(String)->Unit, onCompleted:()->Unit){
|
||||
|
||||
for (rawName in missingMods) {
|
||||
val modName = rawName.folderNameToRepoName().lowercase()
|
||||
val repos = Github.tryGetGithubReposWithTopic(10, 1, modName)
|
||||
?: throw UncivShowableException("Could not download mod list.")
|
||||
val repo = repos.items.firstOrNull { it.name.lowercase() == modName }
|
||||
?: throw UncivShowableException("Could not find a mod named \"[$modName]\".")
|
||||
val modFolder = Github.downloadAndExtract(
|
||||
repo,
|
||||
UncivGame.Current.files.getModsFolder()
|
||||
)
|
||||
?: throw Exception("Unexpected 404 error") // downloadAndExtract returns null for 404 errors and the like -> display something!
|
||||
Github.rewriteModOptions(repo, modFolder)
|
||||
onModDownloaded(repo.name)
|
||||
}
|
||||
onCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
@ -133,7 +78,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
private fun Table.initRightSideTable() {
|
||||
add(getLoadFromClipboardButton()).row()
|
||||
addLoadFromCustomLocationButton()
|
||||
add(errorLabel).width(stage.width / 2).row()
|
||||
add(errorLabel).width(stage.width / 2).center().row()
|
||||
add(loadMissingModsButton).row()
|
||||
add(deleteSaveButton).row()
|
||||
add(copySavedGameToClipboardButton).row()
|
||||
@ -218,18 +163,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
copyButton.onActivation {
|
||||
val file = selectedSave ?: return@onActivation
|
||||
Concurrency.run(copyExistingSaveToClipboard) {
|
||||
try {
|
||||
val gameText = file.readString()
|
||||
Gdx.app.clipboard.contents = if (gameText[0] == '{') Gzip.zip(gameText) else gameText
|
||||
launchOnGLThread {
|
||||
ToastPopup("'[${file.name()}]' copied to clipboard!", this@LoadGameScreen)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
ex.printStackTrace()
|
||||
launchOnGLThread {
|
||||
ToastPopup("Could not save game to clipboard!", this@LoadGameScreen)
|
||||
}
|
||||
}
|
||||
copySaveToClipboard(file)
|
||||
}
|
||||
}
|
||||
copyButton.disable()
|
||||
@ -239,6 +173,31 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
return copyButton
|
||||
}
|
||||
|
||||
private fun CoroutineScope.copySaveToClipboard(file: FileHandle) {
|
||||
val gameText = try {
|
||||
file.readString()
|
||||
} catch (ex: Throwable) {
|
||||
val (errorText, isUserFixable) = getLoadExceptionMessage(ex, saveToClipboardErrorMessage)
|
||||
if (!isUserFixable)
|
||||
Log.error(saveToClipboardErrorMessage, ex)
|
||||
launchOnGLThread {
|
||||
ToastPopup(errorText, this@LoadGameScreen)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
Gdx.app.clipboard.contents = if (gameText[0] == '{') Gzip.zip(gameText) else gameText
|
||||
launchOnGLThread {
|
||||
ToastPopup("'[${file.name()}]' copied to clipboard!", this@LoadGameScreen)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Log.error(saveToClipboardErrorMessage, ex)
|
||||
launchOnGLThread {
|
||||
ToastPopup(saveToClipboardErrorMessage, this@LoadGameScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLoadMissingModsButton(): TextButton {
|
||||
val button = downloadMissingMods.toTextButton()
|
||||
button.onClick {
|
||||
@ -249,26 +208,20 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
}
|
||||
|
||||
private fun handleLoadGameException(ex: Exception, primaryText: String = "Could not load game!") {
|
||||
Log.error("Error while loading game", ex)
|
||||
val (errorText, isUserFixable) = getLoadExceptionMessage(ex, primaryText)
|
||||
val isUserFixable = handleException(ex, primaryText)
|
||||
if (!isUserFixable) {
|
||||
val cantLoadGamePopup = Popup(this@LoadGameScreen)
|
||||
cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row()
|
||||
cantLoadGamePopup.addGoodSizedLabel("If you could copy your game data (\"Copy saved game to clipboard\" - ").row()
|
||||
cantLoadGamePopup.addGoodSizedLabel(" paste into an email to yairm210@hotmail.com)").row()
|
||||
cantLoadGamePopup.addGoodSizedLabel("I could maybe help you figure out what went wrong, since this isn't supposed to happen!").row()
|
||||
cantLoadGamePopup.addCloseButton()
|
||||
cantLoadGamePopup.open()
|
||||
}
|
||||
|
||||
Concurrency.runOnGLThread {
|
||||
if (!isUserFixable) {
|
||||
val cantLoadGamePopup = Popup(this@LoadGameScreen)
|
||||
cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row()
|
||||
cantLoadGamePopup.addGoodSizedLabel("If you could copy your game data (\"Copy saved game to clipboard\" - ").row()
|
||||
cantLoadGamePopup.addGoodSizedLabel(" paste into an email to yairm210@hotmail.com)").row()
|
||||
cantLoadGamePopup.addGoodSizedLabel("I could maybe help you figure out what went wrong, since this isn't supposed to happen!").row()
|
||||
cantLoadGamePopup.addCloseButton()
|
||||
cantLoadGamePopup.open()
|
||||
}
|
||||
|
||||
errorLabel.setText(errorText)
|
||||
errorLabel.isVisible = true
|
||||
if (ex is MissingModsException) {
|
||||
loadMissingModsButton.isVisible = true
|
||||
missingModsToLoad = ex.missingMods
|
||||
}
|
||||
if (ex is MissingModsException) {
|
||||
loadMissingModsButton.isVisible = true
|
||||
missingModsToLoad = ex.missingMods
|
||||
}
|
||||
}
|
||||
|
||||
@ -277,7 +230,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
descriptionLabel.setText(Constants.loading.tr())
|
||||
Concurrency.runOnNonDaemonThreadPool(downloadMissingMods) {
|
||||
try {
|
||||
Companion.loadMissingMods(missingModsToLoad,
|
||||
loadMissingMods(missingModsToLoad,
|
||||
onModDownloaded = {
|
||||
val labelText = descriptionLabel.text // Surprise - a StringBuilder
|
||||
labelText.appendLine()
|
||||
@ -296,7 +249,9 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
}
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
handleLoadGameException(ex, "Could not load the missing mods!")
|
||||
launchOnGLThread {
|
||||
handleLoadGameException(ex, "Could not load the missing mods!")
|
||||
}
|
||||
} finally {
|
||||
launchOnGLThread {
|
||||
loadMissingModsButton.isEnabled = true
|
||||
@ -305,5 +260,4 @@ class LoadGameScreen : LoadOrSaveScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,11 +1,18 @@
|
||||
package com.unciv.ui.screens.savescreens
|
||||
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
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.TextButton
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.badlogic.gdx.utils.GdxRuntimeException
|
||||
import com.badlogic.gdx.utils.SerializationException
|
||||
import com.unciv.Constants
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.UncivShowableException
|
||||
import com.unciv.logic.github.Github
|
||||
import com.unciv.logic.github.Github.folderNameToRepoName
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
|
||||
import com.unciv.ui.components.extensions.UncivDateFormat.formatDate
|
||||
@ -23,8 +30,13 @@ import com.unciv.ui.components.input.onDoubleClick
|
||||
import com.unciv.ui.popups.ConfirmPopup
|
||||
import com.unciv.ui.screens.pickerscreens.PickerScreen
|
||||
import com.unciv.utils.Concurrency
|
||||
import com.unciv.utils.Log
|
||||
import com.unciv.utils.launchOnGLThread
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.file.attribute.DosFileAttributes
|
||||
import java.util.Date
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.readAttributes
|
||||
|
||||
|
||||
abstract class LoadOrSaveScreen(
|
||||
@ -41,6 +53,11 @@ abstract class LoadOrSaveScreen(
|
||||
protected val rightSideTable = Table()
|
||||
protected val deleteSaveButton = "Delete save".toTextButton(skin.get("negative", TextButton.TextButtonStyle::class.java))
|
||||
protected val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin)
|
||||
protected val errorLabel = "".toLabel(Color.RED, alignment = Align.center)
|
||||
|
||||
companion object : Helpers {
|
||||
internal const val saveToClipboardErrorMessage = "Could not save game to clipboard!"
|
||||
}
|
||||
|
||||
init {
|
||||
savesScrollPane.onChange(::selectExistingSave)
|
||||
@ -103,6 +120,7 @@ abstract class LoadOrSaveScreen(
|
||||
}
|
||||
|
||||
private fun selectExistingSave(saveGameFile: FileHandle) {
|
||||
errorLabel.isVisible = false
|
||||
deleteSaveButton.enable()
|
||||
|
||||
selectedSave = saveGameFile
|
||||
@ -135,4 +153,83 @@ abstract class LoadOrSaveScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Show appropriate message for an exception and log the severe cases
|
||||
* @return isUserFixable */
|
||||
protected fun handleException(ex: Exception, primaryText: String, file: FileHandle? = null): Boolean {
|
||||
val (errorText, isUserFixable) = getLoadExceptionMessage(ex, primaryText, file)
|
||||
if (!isUserFixable)
|
||||
Log.error(primaryText, ex)
|
||||
errorLabel.setText(errorText)
|
||||
errorLabel.isVisible = true
|
||||
return isUserFixable
|
||||
}
|
||||
|
||||
interface Helpers {
|
||||
/** Gets a translated exception message to show to the user.
|
||||
* @param file Optional, used for detection of local read-only files, only relevant for write operations on Windows desktops.
|
||||
* @return The first returned value is the message, the second is signifying if the user can likely fix this problem. */
|
||||
fun getLoadExceptionMessage(ex: Throwable, primaryText: String = "Could not load game!", file: FileHandle? = null): Pair<String, Boolean> {
|
||||
val errorText = StringBuilder(primaryText.tr())
|
||||
errorText.appendLine()
|
||||
var cause = ex
|
||||
while (cause.cause != null && cause is GdxRuntimeException) cause = cause.cause!!
|
||||
|
||||
fun FileHandle.isReadOnly(): Boolean {
|
||||
try {
|
||||
val attr = Path(file().absolutePath).readAttributes<DosFileAttributes>()
|
||||
return attr.isReadOnly
|
||||
} catch (_: Throwable) { return false }
|
||||
}
|
||||
|
||||
val isUserFixable = when (cause) {
|
||||
is UncivShowableException -> {
|
||||
errorText.append(ex.localizedMessage)
|
||||
true
|
||||
}
|
||||
is SerializationException -> {
|
||||
errorText.append("The file data seems to be corrupted.".tr())
|
||||
false
|
||||
}
|
||||
is FileNotFoundException -> {
|
||||
// This is thrown both for chmod/ACL denials and for writing to a file with the read-only attribute set
|
||||
// On Windows, `message` is already (and illegally) partially localized.
|
||||
val localizedMessage = UncivGame.Current.getSystemErrorMessage(5)
|
||||
val isPermissionDenied = cause.message?.run {
|
||||
contains("Permission denied") || (localizedMessage != null && contains(localizedMessage))
|
||||
} == true
|
||||
if (isPermissionDenied) {
|
||||
if (file != null && file.isReadOnly())
|
||||
errorText.append("The file is marked read-only.".tr())
|
||||
else
|
||||
errorText.append("You do not have sufficient permissions to access the file.".tr())
|
||||
}
|
||||
isPermissionDenied
|
||||
}
|
||||
else -> {
|
||||
errorText.append("Unhandled problem, [${ex::class.simpleName} ${ex.stackTraceToString()}]".tr())
|
||||
false
|
||||
}
|
||||
}
|
||||
return Pair(errorText.toString(), isUserFixable)
|
||||
}
|
||||
|
||||
fun loadMissingMods(missingMods: Iterable<String>, onModDownloaded:(String)->Unit, onCompleted:()->Unit) {
|
||||
for (rawName in missingMods) {
|
||||
val modName = rawName.folderNameToRepoName().lowercase()
|
||||
val repos = Github.tryGetGithubReposWithTopic(10, 1, modName)
|
||||
?: throw UncivShowableException("Could not download mod list.")
|
||||
val repo = repos.items.firstOrNull { it.name.lowercase() == modName }
|
||||
?: throw UncivShowableException("Could not find a mod named \"[$modName]\".")
|
||||
val modFolder = Github.downloadAndExtract(
|
||||
repo,
|
||||
UncivGame.Current.files.getModsFolder()
|
||||
)
|
||||
?: throw Exception("Unexpected 404 error") // downloadAndExtract returns null for 404 errors and the like -> display something!
|
||||
Github.rewriteModOptions(repo, modFolder)
|
||||
onModDownloaded(repo.name)
|
||||
}
|
||||
onCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package com.unciv.ui.screens.savescreens
|
||||
|
||||
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.unciv.UncivGame
|
||||
import com.unciv.logic.GameInfo
|
||||
@ -13,6 +12,7 @@ import com.unciv.ui.components.widgets.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.isEnabled
|
||||
import com.unciv.ui.components.extensions.toLabel
|
||||
import com.unciv.ui.components.extensions.toTextButton
|
||||
import com.unciv.ui.components.input.KeyCharAndCode
|
||||
@ -22,24 +22,29 @@ import com.unciv.ui.components.input.onClick
|
||||
import com.unciv.ui.popups.ConfirmPopup
|
||||
import com.unciv.ui.popups.ToastPopup
|
||||
import com.unciv.utils.Concurrency
|
||||
import com.unciv.utils.Log
|
||||
import com.unciv.utils.launchOnGLThread
|
||||
|
||||
|
||||
class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") {
|
||||
companion object {
|
||||
class SaveGameScreen(private val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") {
|
||||
private val gameNameTextField = UncivTextField(nameFieldLabelText)
|
||||
|
||||
companion object : Helpers {
|
||||
const val nameFieldLabelText = "Saved game name"
|
||||
const val saveButtonText = "Save game"
|
||||
const val savingText = "Saving..."
|
||||
const val saveToCustomText = "Save to custom location"
|
||||
}
|
||||
|
||||
private val gameNameTextField = UncivTextField(nameFieldLabelText)
|
||||
|
||||
init {
|
||||
errorLabel.isVisible = false
|
||||
errorLabel.wrap = true
|
||||
|
||||
setDefaultCloseAction()
|
||||
|
||||
rightSideTable.initRightSideTable()
|
||||
|
||||
rightSideButton.setText("Save game".tr())
|
||||
rightSideButton.setText(saveButtonText.tr())
|
||||
rightSideButton.onActivation {
|
||||
if (game.files.getSave(gameNameTextField.text).exists())
|
||||
doubleClickAction()
|
||||
@ -53,19 +58,22 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
|
||||
addGameNameField()
|
||||
|
||||
val copyJsonButton = "Copy to clipboard".toTextButton()
|
||||
copyJsonButton.onActivation { copyToClipboardHandler() }
|
||||
copyJsonButton.onActivation(::copyToClipboardHandler)
|
||||
val ctrlC = KeyCharAndCode.ctrl('c')
|
||||
copyJsonButton.keyShortcuts.add(ctrlC)
|
||||
copyJsonButton.addTooltip(ctrlC)
|
||||
add(copyJsonButton).row()
|
||||
|
||||
addSaveToCustomLocation()
|
||||
add(errorLabel).width(stage.width / 2).center().row()
|
||||
row() // For uniformity with LoadScreen which has a load missing mods button here
|
||||
add(deleteSaveButton).row()
|
||||
add(showAutosavesCheckbox).row()
|
||||
}
|
||||
|
||||
private fun Table.addGameNameField() {
|
||||
gameNameTextField.setTextFieldFilter { _, char -> char != '\\' && char != '/' }
|
||||
gameNameTextField.textFieldFilter = UncivFiles.fileNameTextFieldFilter()
|
||||
gameNameTextField.setTextFieldListener { textField, _ -> enableSaveButton(textField.text) }
|
||||
val defaultSaveName = "[${gameInfo.currentPlayer}] - [${gameInfo.turns}] turns".tr(hideIcons = true)
|
||||
gameNameTextField.text = defaultSaveName
|
||||
gameNameTextField.setSelection(0, defaultSaveName.length)
|
||||
@ -74,15 +82,22 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
|
||||
add(gameNameTextField).width(300f).row()
|
||||
}
|
||||
|
||||
private fun enableSaveButton(text: String) {
|
||||
rightSideButton.isEnabled = UncivFiles.isValidFileName(text)
|
||||
}
|
||||
|
||||
private fun copyToClipboardHandler() {
|
||||
Concurrency.run("Copy game to clipboard") {
|
||||
// the Gzip rarely leads to ANRs
|
||||
try {
|
||||
Gdx.app.clipboard.contents = UncivFiles.gameInfoToString(gameInfo, forceZip = true)
|
||||
} catch (ex: Throwable) {
|
||||
ex.printStackTrace()
|
||||
launchOnGLThread {
|
||||
ToastPopup("Could not save game to clipboard!", this@SaveGameScreen)
|
||||
ToastPopup("Current game copied to clipboard!", this@SaveGameScreen)
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Log.error(saveToClipboardErrorMessage, ex)
|
||||
launchOnGLThread {
|
||||
ToastPopup(saveToClipboardErrorMessage, this@SaveGameScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -90,11 +105,10 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
|
||||
|
||||
private fun Table.addSaveToCustomLocation() {
|
||||
val saveToCustomLocation = saveToCustomText.toTextButton()
|
||||
val errorLabel = "".toLabel(Color.RED)
|
||||
saveToCustomLocation.onClick {
|
||||
errorLabel.setText("")
|
||||
saveToCustomLocation.setText(savingText.tr())
|
||||
saveToCustomLocation.disable()
|
||||
errorLabel.isVisible = false
|
||||
Concurrency.runOnNonDaemonThreadPool(saveToCustomText) {
|
||||
|
||||
game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text,
|
||||
@ -103,8 +117,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
|
||||
},
|
||||
{
|
||||
if (it !is PlatformSaverLoader.Cancelled) {
|
||||
errorLabel.setText("Could not save game to custom location!".tr())
|
||||
it.printStackTrace()
|
||||
handleException(it, "Could not save game to custom location!")
|
||||
}
|
||||
saveToCustomLocation.setText(saveToCustomText.tr())
|
||||
saveToCustomLocation.enable()
|
||||
@ -113,15 +126,18 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
|
||||
}
|
||||
}
|
||||
add(saveToCustomLocation).row()
|
||||
add(errorLabel).row()
|
||||
}
|
||||
|
||||
private fun saveGame() {
|
||||
rightSideButton.setText(savingText.tr())
|
||||
errorLabel.isVisible = false
|
||||
Concurrency.runOnNonDaemonThreadPool("SaveGame") {
|
||||
game.files.saveGame(gameInfo, gameNameTextField.text) {
|
||||
launchOnGLThread {
|
||||
if (it != null) ToastPopup("Could not save game!", this@SaveGameScreen)
|
||||
if (it != null) {
|
||||
handleException(it, "Could not save game!", game.files.getSave(gameNameTextField.text))
|
||||
rightSideButton.setText(saveButtonText.tr())
|
||||
}
|
||||
else UncivGame.Current.popScreen()
|
||||
}
|
||||
}
|
||||
|
@ -10,4 +10,7 @@ interface PlatformSpecific {
|
||||
|
||||
/** If not null, this is the path to the directory in which to store the local files - mods, saves, maps, etc */
|
||||
var customDataDirectory: String?
|
||||
|
||||
/** If the OS localizes all error messages, this should provide a lookup */
|
||||
fun getSystemErrorMessage(errorCode: Int): String? = null
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package com.unciv.app.desktop
|
||||
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
|
||||
import com.sun.jna.platform.win32.Kernel32Util
|
||||
import com.unciv.UncivGame
|
||||
|
||||
class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDataDirectory: String?) : UncivGame() {
|
||||
@ -48,4 +49,14 @@ class DesktopGame(config: Lwjgl3ApplicationConfiguration, override var customDat
|
||||
discordUpdater.stopUpdates()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
override fun getSystemErrorMessage(errorCode: Int): String? {
|
||||
return try {
|
||||
if (System.getProperty("os.name")?.contains("Windows") == true)
|
||||
Kernel32Util.formatMessage(errorCode)
|
||||
else null
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user