Allow access to the Dev Console on mobile devices (#11588)

* Make DeveloperConsole callable from WorldScreenMenuPopup

* Better WorldScreenMenuPopup single/dual-column logic

* Allow developer console to stay open and persist history

* Fix console tab key closing onscreen keyboard

* Give the now persisted console history an upper size limit

* Ensure "up" always gives the new entry

* Fix merge leftovers

* Reviews
This commit is contained in:
SomeTroglodyte
2024-05-29 22:59:35 +02:00
committed by GitHub
parent c6e3ff5d1b
commit 655c98a7e5
8 changed files with 130 additions and 60 deletions

View File

@ -128,6 +128,11 @@ class GameSettings {
/** Size of automatic display of UnitSet art in Civilopedia - 0 to disable */
var pediaUnitArtSize = 0f
/** Don't close developer console after a successful command */
var keepConsoleOpen = false
/** Persist the history of successful developer console commands */
val consoleCommandHistory = ArrayList<String>()
/** used to migrate from older versions of the settings */
var version: Int? = null

View File

@ -17,6 +17,7 @@ import com.unciv.ui.components.extensions.getOverlap
import com.unciv.ui.components.extensions.right
import com.unciv.ui.components.extensions.stageBoundingBox
import com.unciv.ui.components.extensions.top
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen
@ -42,7 +43,12 @@ object UncivTextField {
*/
fun create(hint: String, preEnteredText: String = "", onFocusChange: (TextField.(Boolean) -> Unit)? = null): TextField {
@Suppress("UNCIV_RAW_TEXTFIELD")
val textField = TextField(preEnteredText, BaseScreen.skin)
val textField = object : TextField(preEnteredText, BaseScreen.skin) {
override fun next(up: Boolean) {
if (KeyCharAndCode.TAB in keyShortcuts) return
super.next(up)
}
}
val translatedHint = hint.tr()
textField.messageText = translatedHint
textField.addListener(object : FocusListener() {

View File

@ -61,6 +61,15 @@ open class KeyShortcutDispatcher {
shortcuts.removeAll { it.shortcut.key.code == keyCode }
}
operator fun contains(binding: KeyboardBinding) =
shortcuts.any { it.shortcut.binding == binding }
operator fun contains(key: KeyCharAndCode) =
shortcuts.any { it.shortcut.key == key || it.shortcut.binding.defaultKey == key }
operator fun contains(char: Char) =
shortcuts.any { it.shortcut.key.char == char }
operator fun contains(keyCode: Int) =
shortcuts.any { it.shortcut.key.code == keyCode }
open fun isActive(): Boolean = true

View File

@ -68,8 +68,8 @@ open class Popup(
*/
enum class Scrollability { None, All, WithoutButtons }
private val maxPopupWidth = stageToShowOn.width * maxSizePercentage
private val maxPopupHeight = stageToShowOn.height * maxSizePercentage
protected val maxPopupWidth = stageToShowOn.width * maxSizePercentage
protected val maxPopupHeight = stageToShowOn.height * maxSizePercentage
/** This exists to differentiate the actual popup (this table)
* from the 'screen blocking' part of the popup (which covers the entire screen).

View File

@ -3,81 +3,100 @@ package com.unciv.ui.screens.devconsole
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.Constants
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.extensions.toCheckBox
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
class DevConsolePopup(val screen: WorldScreen) : Popup(screen) {
companion object {
val history = ArrayList<String>()
private const val maxHistorySize = 42
}
private val history by screen.game.settings::consoleCommandHistory
private var keepOpen by screen.game.settings::keepConsoleOpen
private var currentHistoryEntry = history.size
private val textField = TextField("", BaseScreen.skin)
private val textField = UncivTextField.create("", "") // always has focus, so a hint won't show
private val responseLabel = "".toLabel(Color.RED).apply { wrap = true }
private val commandRoot = ConsoleCommandRoot()
internal val gameInfo = screen.gameInfo
init {
add(textField).width(stageToShowOn.width / 2).row()
// Use untranslated text here! The entire console, including messages, should stay English.
// But "Developer Console" *has* a translation from KeyboardBinding.DeveloperConsole.
// The extensions still help, even with a "don't translate" kludge ("Keep open" has no template but might in the future).
add("Developer Console {}".toLabel(fontSize = Constants.headingFontSize)).growX() // translation template is automatic via the keybinding
add("Keep open {}".toCheckBox(keepOpen) { keepOpen = it }).right().row()
add(textField).width(stageToShowOn.width / 2).colspan(2).row()
textField.keyShortcuts.add(Input.Keys.ENTER, ::onEnter)
// Without this, console popup will always contain a `
// Without this, console popup will always contain the key used to open it - won't work perfectly if it's configured to a "dead key"
textField.addAction(Actions.delay(0.05f, Actions.run { textField.text = "" }))
add(responseLabel).maxWidth(screen.stage.width * 0.8f)
open(true)
add(responseLabel).colspan(2).maxWidth(screen.stage.width * 0.8f)
keyShortcuts.add(KeyCharAndCode.BACK) { close() }
clickBehindToClose = true
keyShortcuts.add(KeyCharAndCode.TAB) {
textField.keyShortcuts.add(KeyCharAndCode.TAB) {
val textToAdd = getAutocomplete()
textField.appendText(textToAdd)
}
if (history.isNotEmpty()) {
keyShortcuts.add(Input.Keys.UP) {
if (currentHistoryEntry > 0) currentHistoryEntry--
textField.text = history[currentHistoryEntry]
textField.cursorPosition = textField.text.length
}
keyShortcuts.add(Input.Keys.DOWN) {
if (currentHistoryEntry == history.size) currentHistoryEntry--
if (currentHistoryEntry < history.lastIndex) currentHistoryEntry++
textField.text = history[currentHistoryEntry]
textField.cursorPosition = textField.text.length
}
}
keyShortcuts.add(Input.Keys.UP) { navigateHistory(-1) }
keyShortcuts.add(Input.Keys.DOWN) { navigateHistory(1) }
open(true)
screen.stage.keyboardFocus = textField
}
private fun navigateHistory(delta: Int) {
if (history.isEmpty()) return
currentHistoryEntry = (currentHistoryEntry + delta).coerceIn(history.indices)
textField.text = history[currentHistoryEntry]
textField.cursorPosition = textField.text.length
}
private fun onEnter() {
val handleCommandResponse = handleCommand()
if (handleCommandResponse.isOK) {
screen.shouldUpdate = true
if (history.isEmpty() || history.last() != textField.text)
history.add(textField.text)
close()
addHistory()
if (!keepOpen) close() else textField.text = ""
return
}
showResponse(handleCommandResponse.message, handleCommandResponse.color)
}
private fun addHistory() {
val text = textField.text
if (text.isBlank()) return
if (history.isNotEmpty() && history.last().equals(text, true)) return
if (history.size >= maxHistorySize) {
history.removeAll { it.equals(text, true) }
if (history.size >= maxHistorySize)
history.removeAt(0)
}
history.add(textField.text)
currentHistoryEntry = history.size
}
internal fun showResponse(message: String?, color: Color) {
responseLabel.setText(message)
responseLabel.style.fontColor = color
}
val splitStringRegex = Regex("\"([^\"]+)\"|\\S+") // Read: "(phrase)" OR non-whitespace
private val splitStringRegex = Regex("\"([^\"]+)\"|\\S+") // Read: "(phrase)" OR non-whitespace
private fun getParams(text: String): List<String> {
return splitStringRegex.findAll(text).map { it.value.removeSurrounding("\"") }.filter { it.isNotEmpty() }.toList()
}

View File

@ -21,7 +21,6 @@ import com.unciv.logic.multiplayer.storage.MultiplayerAuthException
import com.unciv.logic.trade.TradeEvaluation
import com.unciv.models.TutorialTrigger
import com.unciv.models.metadata.GameSetupInfo
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.components.extensions.centerX
@ -38,7 +37,6 @@ import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.popups.hasOpenPopups
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.cityscreen.CityScreen
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
import com.unciv.ui.screens.devconsole.DevConsolePopup
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
import com.unciv.ui.screens.newgamescreen.NewGameScreen
@ -285,11 +283,13 @@ class WorldScreen(
globalShortcuts.add(KeyboardBinding.ToggleWorkedTilesDisplay) { minimapWrapper.populationImageButton.toggle() }
globalShortcuts.add(KeyboardBinding.ToggleMovementDisplay) { minimapWrapper.movementsImageButton.toggle() }
globalShortcuts.add(KeyboardBinding.DeveloperConsole) {
// No cheating unless you're by yourself
if (gameInfo.civilizations.count { it.isHuman() } > 1) return@add
val consolePopup = DevConsolePopup(this)
globalShortcuts.add(KeyboardBinding.DeveloperConsole, action = ::openDeveloperConsole)
}
fun openDeveloperConsole() {
// No cheating unless you're by yourself
if (gameInfo.civilizations.count { it.isHuman() } > 1) return
val consolePopup = DevConsolePopup(this)
}
private fun toggleUI() {

View File

@ -1,43 +1,67 @@
package com.unciv.ui.screens.worldscreen.mainmenu
import com.unciv.UncivGame
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.onLongPress
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.victoryscreen.VictoryScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen, scrollable = Scrollability.All) {
/** The in-game menu called from the "Hamburger" button top-left
*
* Popup automatically opens as soon as it's initialized
*/
class WorldScreenMenuPopup(
val worldScreen: WorldScreen,
expertMode: Boolean = false
) : Popup(worldScreen, scrollable = Scrollability.All) {
private val singleColumn: Boolean
private fun <T: Actor?> Cell<T>.nextColumn() {
if (!singleColumn && column == 0) return
row()
}
init {
worldScreen.autoPlay.stopAutoPlay()
defaults().fillX()
addButton("Main menu") {
val showSave = !worldScreen.gameInfo.gameParameters.isOnlineMultiplayer
val showMusic = worldScreen.game.musicController.isMusicAvailable()
val showConsole = showSave && expertMode
val buttonCount = 8 + (if (showSave) 1 else 0) + (if (showMusic) 1 else 0) + (if (showConsole) 1 else 0)
val emptyPrefHeight = this.prefHeight
val firstCell = addButton("Main menu") {
worldScreen.game.goToMainMenu()
}.row()
}
singleColumn = worldScreen.isCrampedPortrait() ||
2 * prefWidth > maxPopupWidth || // Very coarse: Assume width of translated "Main menu" is representative
buttonCount * (prefHeight - emptyPrefHeight) + emptyPrefHeight < maxPopupHeight
firstCell.nextColumn()
addButton("Civilopedia", KeyboardBinding.Civilopedia) {
close()
worldScreen.openCivilopedia()
}.row()
if (!worldScreen.gameInfo.gameParameters.isOnlineMultiplayer)
}.nextColumn()
if (showSave)
addButton("Save game", KeyboardBinding.SaveGame) {
close()
worldScreen.openSaveGameScreen()
}.row()
}.nextColumn()
addButton("Load game", KeyboardBinding.LoadGame) {
close()
worldScreen.game.pushScreen(LoadGameScreen())
}.row()
}.nextColumn()
addButton("Start new game", KeyboardBinding.NewGame) {
close()
worldScreen.openNewGameScreen()
}.row()
}.nextColumn()
addButton("Victory status", KeyboardBinding.VictoryScreen) {
close()
worldScreen.game.pushScreen(VictoryScreen(worldScreen))
}.row()
}.nextColumn()
val optionsCell = addButton("Options", KeyboardBinding.Options) {
close()
worldScreen.openOptionsPopup()
@ -46,17 +70,26 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen, sc
close()
worldScreen.openOptionsPopup(withDebug = true)
}
optionsCell.row()
optionsCell.nextColumn()
addButton("Community") {
close()
WorldScreenCommunityPopup(worldScreen).open(force = true)
}.row()
}.nextColumn()
if (showMusic)
addButton("Music", KeyboardBinding.MusicPlayer) {
close()
WorldScreenMusicPopup(worldScreen).open(force = true)
}.row()
}.nextColumn()
addCloseButton()
if (showConsole)
addButton("Developer Console", KeyboardBinding.DeveloperConsole) {
close()
worldScreen.openDeveloperConsole()
}.nextColumn()
addCloseButton().run { colspan(if (singleColumn || column == 1) 1 else 2) }
pack()
open(force = true)
}
}

View File

@ -16,10 +16,9 @@ import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.input.onRightClick
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
import com.unciv.ui.screens.overviewscreen.EmpireOverviewCategories
import com.unciv.ui.screens.worldscreen.BackgroundActor
import com.unciv.ui.screens.worldscreen.WorldScreen
@ -212,9 +211,8 @@ class WorldScreenTopBar(internal val worldScreen: WorldScreen) : Table() {
padTop((10f - descenderHeight).coerceAtLeast(0f))
menuButton.color = Color.WHITE
menuButton.onActivation(binding = KeyboardBinding.Menu) {
WorldScreenMenuPopup(worldScreen).open(force = true)
}
menuButton.onActivation(binding = KeyboardBinding.Menu) { WorldScreenMenuPopup(worldScreen) }
menuButton.onRightClick { WorldScreenMenuPopup(worldScreen, true) }
val onNationClick = {
worldScreen.openCivilopedia(worldScreen.selectedCiv.nation.makeLink())