diff --git a/core/src/com/unciv/logic/files/FileChooser.kt b/core/src/com/unciv/logic/files/FileChooser.kt new file mode 100644 index 0000000000..fea90d6cc6 --- /dev/null +++ b/core/src/com/unciv/logic/files/FileChooser.kt @@ -0,0 +1,294 @@ +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.graphics.Color +import com.badlogic.gdx.graphics.g2d.Batch +import com.badlogic.gdx.graphics.g2d.BitmapFont +import com.badlogic.gdx.graphics.g2d.GlyphLayout +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.Stage +import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.ui.Cell +import com.badlogic.gdx.scenes.scene2d.ui.List +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.AutoScrollPane +import com.unciv.ui.components.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.onChange +import com.unciv.ui.components.extensions.onClick +import com.unciv.ui.components.extensions.onDoubleClick +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.popups.Popup +import java.io.File +import java.io.FileFilter + +typealias ResultListener = (success: Boolean, file: FileHandle) -> Unit + +/** + * A file picker written in Gdx as using java.awt.JFileChooser or java.awt.FileDialog crashes on X11 desktops + * + * Based loosely on [olli-pekka's original][https://jvm-gaming.org/t/libgdx-scene2d-ui-filechooser-dialog/53228] + */ + +@Suppress("unused", "MemberVisibilityCanBePrivate") // This has optional API features +open class FileChooser( + stageToShowOn: Stage, + title: String?, + startFile: FileHandle? = null, + private val resultListener: ResultListener? = null +) : Popup(stageToShowOn, false) { + // config + var filter = FileFilter { true } + set(value) { field = value; resetList() } + var directoryBrowsingEnabled = true + set(value) { field = value; resetList() } + var allowFolderSelect = false + set(value) { field = value; resetList() } + var showAbsolutePath = false + set(value) { field = value; resetList() } + var fileNameEnabled + get() = fileNameCell.hasActor() + set(value) { + if (value) fileNameCell.setActor(fileNameWrapper) + else fileNameCell.clearActor() + } + + // components + private val fileNameInput = UncivTextField.create("Please enter a file name") + private val fileNameLabel = "File name:".toLabel() + private val fileNameWrapper = Table().apply { + defaults().space(10f) + add(fileNameLabel).growX().row() + add(fileNameInput).growX().row() + addSeparator(height = 1f) + } + private val fileNameCell: Cell + private val dirTypeLabel = "".toLabel(Color.GRAY) // color forces a style clone + private val pathLabel = "".toLabel(Color.GRAY, alignment = Align.left) + private val pathLabelWrapper = Table().apply { + touchable = Touchable.enabled + defaults().space(10f) + add(dirTypeLabel).pad(2f) + add(pathLabel).left().growX() + } + private val fileList = FileList(skin) + private val fileScroll = AutoScrollPane(fileList) + private val okButton: TextButton + + // operational + private val maxHeight = stageToShowOn.height * 0.6f + private val absoluteLocalPath = Gdx.files.local("").file().absoluteFile.canonicalPath + private val absoluteExternalPath = if (Gdx.files.isExternalStorageAvailable) + Gdx.files.external("").file().absoluteFile.canonicalPath + else "/\\/\\/" // impossible placeholder + private var currentDir: FileHandle? = null + private var result: String? = null + private val filterWithFolders = FileFilter { + directoryBrowsingEnabled && it.isDirectory || filter.accept(it) + } + + private class FileListItem(val label: String, val file: FileHandle, val isFolder: Boolean) { + constructor(file: FileHandle) : this( + label = (if (file.isDirectory) " " else "") + file.name(), + file, + isFolder = file.isDirectory // cache, it's not trivially cheap + ) + override fun toString() = label + } + + private class FileList(skin: Skin) : List(skin) { + val saveColor = Color() + val folderColor = Color(1f, 0.86f, 0.5f, 1f) // #ffdb80, same Hue as Goldenrod but S=50 V=100 + @Suppress("UsePropertyAccessSyntax") + override fun drawItem(batch: Batch, font: BitmapFont, index: Int, item: FileListItem, + x: Float, y: Float, width: Float): GlyphLayout { + saveColor.set(font.color) + font.setColor(if (item.isFolder) folderColor else Color.WHITE) + val layout = super.drawItem(batch, font, index, item, x, y, width) + font.setColor(saveColor) + return layout + } + } + + init { + innerTable.top().left() + + fileList.selection.setProgrammaticChangeEvents(false) + fileNameInput.setTextFieldListener { textField, _ -> result = textField.text } + + if (title != null) { + addGoodSizedLabel(title).colspan(2).center().row() + innerTable.addSeparator(height = 1f) + } + add(pathLabelWrapper).colspan(2).fillX().row() + innerTable.addSeparator(Color.GRAY, height = 1f) + add(fileScroll).colspan(2).fill().row() + innerTable.addSeparator(height = 1f) + fileNameCell = innerTable.add().colspan(2).growX() + innerTable.row() + + addCloseButton("Cancel", KeyboardBinding.Cancel) { + reportResult(false) + } + okButton = addOKButton(Constants.OK, KeyboardBinding.Confirm) { + reportResult(true) + }.actor + equalizeLastTwoButtonWidths() + + fileList.onChange { + val selected = fileList.selected ?: return@onChange + if (!selected.file.isDirectory) { + result = selected.file.name() + fileNameInput.text = result + } + enableOKButton() + } + fileList.onDoubleClick(UncivSound.Silent) { + val selected = fileList.selected ?: return@onDoubleClick + if (selected.file.isDirectory) + changeDirectory(selected.file) + else { + reportResult(true) + close() + } + } + pathLabelWrapper.onClick(UncivSound.Swap) { switchDomain() } + + showListeners.add { + if (currentDir == null) initialDirectory(startFile) + stageToShowOn.scrollFocus = fileScroll + stageToShowOn.keyboardFocus = fileNameInput + } + } + + override fun getMaxHeight() = maxHeight + + private fun reportResult(success: Boolean) { + resultListener?.invoke(success, getResult()) + } + + private fun makeAbsolute(file: FileHandle): FileHandle { + if (file.type() == Files.FileType.Absolute) return file + return Gdx.files.absolute(file.file().absoluteFile.canonicalPath) + } + + private fun makeRelative(file: FileHandle): FileHandle { + if (file.type() != Files.FileType.Absolute) return file + val path = file.path() + if (path.startsWith(absoluteLocalPath)) + return Gdx.files.local(path.removePrefix(absoluteLocalPath).removePrefix(File.separator)) + if (path.startsWith(absoluteExternalPath)) + return Gdx.files.external(path.removePrefix(absoluteExternalPath).removePrefix(File.separator)) + return file + } + + private fun initialDirectory(startFile: FileHandle?) { + changeDirectory(makeAbsolute(when { + startFile == null && Gdx.files.isExternalStorageAvailable -> + Gdx.files.absolute(absoluteExternalPath) + startFile == null -> + Gdx.files.absolute(absoluteLocalPath) + startFile.isDirectory -> startFile + else -> startFile.parent() + })) + } + + private fun switchDomain() { + val current = currentDir?.path() ?: return + changeDirectory(Gdx.files.absolute(when { + !Gdx.files.isExternalStorageAvailable -> absoluteLocalPath + current.startsWith(absoluteExternalPath) && !current.startsWith(absoluteLocalPath) + -> absoluteLocalPath + else -> absoluteExternalPath + })) + } + + private fun changeDirectory(directory: FileHandle) { + currentDir = directory + + val relativeFile = if (showAbsolutePath) directory else makeRelative(directory) + val (label, color) = when (relativeFile.type()) { + Files.FileType.External -> "Ⓔ" to Color.CHARTREUSE + Files.FileType.Local -> "Ⓛ" to Color.TAN + else -> "" to Color.WHITE + } + dirTypeLabel.setText(label) + dirTypeLabel.color.set(color) + pathLabel.setText(relativeFile.path()) + + val list = directory.list(filterWithFolders) + val items = GdxArray(list.size) + for (handle in list) { + if (!directoryBrowsingEnabled && handle.isDirectory) continue + if (handle.file().isHidden) continue + items.add(FileListItem(handle)) + } + items.sort(dirListComparator) + if (directoryBrowsingEnabled && directory.file().parentFile != null) { + items.insert(0, FileListItem(" ..", directory.parent(), true)) + } + fileList.selected = null + fileList.setItems(items) + enableOKButton() + } + + private fun getResult() = makeRelative( + if (result.isNullOrEmpty()) currentDir!! else currentDir!!.child(result) + ) + + private fun resetList() { + if (!hasParent()) return + changeDirectory(currentDir!!) + } + + private fun enableOKButton() { + fun getEnable(): Boolean { + val file = fileList.selected?.file ?: return false + if (!file.exists()) return false + return (allowFolderSelect || !file.isDirectory) + } + okButton.isEnabled = getEnable() + } + + fun setOkButtonText(text: String) { + okButton.setText(text.tr()) + } + + companion object { + private val dirListComparator: Comparator = + Comparator { file1, file2 -> + when { + file1.file.isDirectory && !file2.file.isDirectory -> -1 + !file1.file.isDirectory && file2.file.isDirectory -> 1 + else -> file1.file.name().compareTo(file2.file.name()) + } + } + + fun createSaveDialog(stage: Stage, title: String?, path: FileHandle? = null, resultListener: ResultListener? = null) = + FileChooser(stage, title, path, resultListener).apply { + fileNameEnabled = true + setOkButtonText("Save") + } + + fun createLoadDialog(stage: Stage, title: String?, path: FileHandle? = null, resultListener: ResultListener? = null) = + FileChooser(stage, title, path, resultListener).apply { + fileNameEnabled = false + setOkButtonText("Load") + } + + fun createExtensionFilter(vararg extensions: String) = FileFilter { + it.extension.lowercase() in extensions + } + } +} diff --git a/core/src/com/unciv/logic/files/LinuxX11SaverLoader.kt b/core/src/com/unciv/logic/files/LinuxX11SaverLoader.kt new file mode 100644 index 0000000000..959151a492 --- /dev/null +++ b/core/src/com/unciv/logic/files/LinuxX11SaverLoader.kt @@ -0,0 +1,53 @@ +package com.unciv.logic.files + +import com.badlogic.gdx.Gdx +import com.unciv.UncivGame +import com.unciv.utils.Concurrency +import java.awt.GraphicsEnvironment + +class LinuxX11SaverLoader : PlatformSaverLoader { + override fun saveGame( + data: String, + suggestedLocation: String, + onSaved: (location: String) -> Unit, + onError: (ex: Exception) -> Unit + ) { + Concurrency.runOnGLThread { + FileChooser.createSaveDialog(stage, "Save game", Gdx.files.absolute(suggestedLocation)) { + success, file -> + if (!success) return@createSaveDialog + try { + file.writeString(data, false, "UTF-8") + onSaved(file.path()) + } catch (ex: Exception) { + onError(ex) + } + }.open(true) + } + } + + override fun loadGame( + onLoaded: (data: String, location: String) -> Unit, + onError: (Exception) -> Unit + ) { + Concurrency.runOnGLThread { + FileChooser.createLoadDialog(stage, "Load game") { success, file -> + if (!success) return@createLoadDialog + try { + val data = file.readString("UTF-8") + onLoaded(data, file.path()) + } catch (ex: Exception) { + onError(ex) + } + }.open(true) + } + } + + val stage get() = UncivGame.Current.screen!!.stage + + companion object { + fun isRequired() = System.getProperty("os.name", "") == "Linux" && + // System.getenv("XDG_SESSION_TYPE") == "x11" - below seems safer + GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.javaClass.simpleName == "X11GraphicsDevice" + } +} diff --git a/core/src/com/unciv/logic/files/PlatformSaverLoader.kt b/core/src/com/unciv/logic/files/PlatformSaverLoader.kt index 0e2157b6a5..f2ca1cfe89 100644 --- a/core/src/com/unciv/logic/files/PlatformSaverLoader.kt +++ b/core/src/com/unciv/logic/files/PlatformSaverLoader.kt @@ -20,4 +20,20 @@ interface PlatformSaverLoader { onLoaded: (data: String, location: String) -> Unit, // On-load-complete callback onError: (Exception) -> Unit = {} // On-load-error callback ) + + companion object { + val None = object : PlatformSaverLoader { + override fun saveGame( + data: String, + suggestedLocation: String, + onSaved: (location: String) -> Unit, + onError: (ex: Exception) -> Unit + ) {} + + override fun loadGame( + onLoaded: (data: String, location: String) -> Unit, + onError: (Exception) -> Unit + ) {} + } + } } diff --git a/core/src/com/unciv/logic/files/UncivFiles.kt b/core/src/com/unciv/logic/files/UncivFiles.kt index 047dad58fa..7e9e95fefa 100644 --- a/core/src/com/unciv/logic/files/UncivFiles.kt +++ b/core/src/com/unciv/logic/files/UncivFiles.kt @@ -320,7 +320,12 @@ class UncivFiles( /** * Platform dependent saver-loader to custom system locations */ - lateinit var saverLoader: PlatformSaverLoader + var saverLoader: PlatformSaverLoader = PlatformSaverLoader.None + get() { + if (field.javaClass.simpleName == "DesktopSaverLoader" && LinuxX11SaverLoader.isRequired()) + field = LinuxX11SaverLoader() + return field + } /** Specialized function to access settings before Gdx is initialized. * diff --git a/core/src/com/unciv/ui/popups/Popup.kt b/core/src/com/unciv/ui/popups/Popup.kt index ae48e067b0..16195ea2be 100644 --- a/core/src/com/unciv/ui/popups/Popup.kt +++ b/core/src/com/unciv/ui/popups/Popup.kt @@ -239,13 +239,11 @@ open class Popup( } /** Overload of [addCloseButton] accepting a bindable key definition as [additionalKey] */ - fun addCloseButton(text: String, additionalKey: KeyboardBinding, action: () -> Unit) { + fun addCloseButton(text: String, additionalKey: KeyboardBinding, action: () -> Unit) = addCloseButton(text, KeyboardBindings[additionalKey], action = action) - } /** Overload of [addOKButton] accepting a bindable key definition as [additionalKey] */ - fun addOKButton(text: String, additionalKey: KeyboardBinding, style: TextButtonStyle? = null, action: () -> Unit) { + fun addOKButton(text: String, additionalKey: KeyboardBinding, style: TextButtonStyle? = null, action: () -> Unit) = addOKButton(text, KeyboardBindings[additionalKey], style, action = action) - } /** * The last two additions ***must*** be buttons.