From aab975a61bb505a9a0ebd8596b7ac23bfc53792a Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Wed, 15 Mar 2023 08:05:50 +0100 Subject: [PATCH] Key bindings Step 3 - Better UI (#8891) * Configurable key bindings - better Widget step 2 * Configurable key bindings - step 2 updated help --- android/assets/jsons/Tutorials.json | 7 +- .../unciv/ui/components/KeyCapturingButton.kt | 69 +++++++++ .../com/unciv/ui/components/KeysSelectBox.kt | 140 ++++++++++++++++++ .../unciv/ui/popups/options/KeyBindingsTab.kt | 110 +++----------- .../unciv/ui/popups/options/OptionsPopup.kt | 1 - 5 files changed, 234 insertions(+), 93 deletions(-) create mode 100644 core/src/com/unciv/ui/components/KeyCapturingButton.kt create mode 100644 core/src/com/unciv/ui/components/KeysSelectBox.kt diff --git a/android/assets/jsons/Tutorials.json b/android/assets/jsons/Tutorials.json index e6a9beecde..0a22143598 100644 --- a/android/assets/jsons/Tutorials.json +++ b/android/assets/jsons/Tutorials.json @@ -401,16 +401,15 @@ {"text":"Currently, there are no checks to prevent conflicting assignments.","starred":true}, {}, {"text":"Using the Keys page","header":3}, - {"text":"Each binding has a label, a text field, and a key button looking like this:"}, + {"text":"Each binding has a label, a dropdown select, and a key button looking like this:"}, {"extraImage":"OtherIcons/Keyboard","imageSize":36}, {"text":"While hovering the mouse over the key button, you can press a desired key directly to assign it."}, - {"text":"Alternatively, you can enter the key's name in the text field."}, - {"text":"To reset a binding to its default, simply clear the text field."}, + {"text":"Alternatively, you can select the key in the dropdown."}, {}, {"text":"Bindings mapped to their default keys are displayed in gray, those reassigned by you in white."}, {}, {"text":"For discussion about missing entries, see the linked github issue.","link":"https://github.com/yairm210/Unciv/issues/8862"} - ], + ] // "uniques": ["Will not be displayed in Civilopedia"] // would prevent use for help link } ] diff --git a/core/src/com/unciv/ui/components/KeyCapturingButton.kt b/core/src/com/unciv/ui/components/KeyCapturingButton.kt new file mode 100644 index 0000000000..df301678b0 --- /dev/null +++ b/core/src/com/unciv/ui/components/KeyCapturingButton.kt @@ -0,0 +1,69 @@ +package com.unciv.ui.components + +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.InputEvent +import com.badlogic.gdx.scenes.scene2d.ui.ImageButton +import com.badlogic.gdx.scenes.scene2d.utils.ClickListener +import com.badlogic.gdx.utils.Align +import com.unciv.ui.components.UncivTooltip.Companion.addTooltip +import com.unciv.ui.images.ImageGetter + +/** A button that captures keyboard keys and reports them through [onKeyHit] */ +class KeyCapturingButton( + private val onKeyHit: (keyCode: Int, control: Boolean) -> Unit +) : ImageButton(getStyle()) { + companion object { + private const val buttonSize = 36f + private const val buttonImage = "OtherIcons/Keyboard" + private val controlKeys = setOf(Input.Keys.CONTROL_LEFT, Input.Keys.CONTROL_RIGHT) + + private fun getStyle() = ImageButtonStyle().apply { + val image = ImageGetter.getDrawable(buttonImage) + imageUp = image + imageOver = image.tint(Color.LIME) + } + } + + private var savedFocus: Actor? = null + + init { + setSize(buttonSize, buttonSize) + addTooltip("Hit the desired key now", 18f, targetAlign = Align.bottomRight) + addListener(ButtonListener(this)) + } + + private class ButtonListener(private val myButton: KeyCapturingButton) : ClickListener() { + private var controlDown = false + + override fun enter(event: InputEvent?, x: Float, y: Float, pointer: Int, fromActor: Actor?) { + if (myButton.stage == null) return + myButton.savedFocus = myButton.stage.keyboardFocus + myButton.stage.keyboardFocus = myButton + } + + override fun exit(event: InputEvent?, x: Float, y: Float, pointer: Int, toActor: Actor?) { + if (myButton.stage == null) return + myButton.stage.keyboardFocus = myButton.savedFocus + myButton.savedFocus = null + } + + override fun keyDown(event: InputEvent?, keycode: Int): Boolean { + if (keycode == Input.Keys.ESCAPE) return false + if (keycode in controlKeys) { + controlDown = true + } else { + myButton.onKeyHit(keycode, controlDown) + } + return true + } + + override fun keyUp(event: InputEvent?, keycode: Int): Boolean { + if (keycode == Input.Keys.ESCAPE) return false + if (keycode in controlKeys) + controlDown = false + return true + } + } +} diff --git a/core/src/com/unciv/ui/components/KeysSelectBox.kt b/core/src/com/unciv/ui/components/KeysSelectBox.kt new file mode 100644 index 0000000000..e8615339a1 --- /dev/null +++ b/core/src/com/unciv/ui/components/KeysSelectBox.kt @@ -0,0 +1,140 @@ +package com.unciv.ui.components + +import com.badlogic.gdx.Input +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.SelectBox +import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener +import com.unciv.models.translations.tr +import com.unciv.ui.screens.basescreen.BaseScreen +import com.badlogic.gdx.utils.Array as GdxArray + + +/** + * A Widget to allow selecting keys from a [SelectBox]. + * + * Added value: + * * Easier change callback as direct [changeCallback] parameter + * * Pulls existing keys from Gdx.[Input.Keys] with an exclusion parameter + * * Key names made translatable as e.g. `Key-"A"` or `Key-Space` + * * Supports TranslationFileWriter by providing those translation keys + */ +// Note this has similarities with TranslatedSelectBox, but having entries in the translation +// files that consist only of a punctuation character wouldn't be pretty +class KeysSelectBox( + private val default: String = "", + excludeKeys: Set = defaultExclusions, + private val changeCallback: (() -> Unit)? = null +) : SelectBox(BaseScreen.skin) { + + class KeysSelectBoxEntry(val name: String) { + val translation = run { + val translationKey = getTranslationKey(name) + val translation = translationKey.tr() + if (translation == translationKey) name else translation + } + + override fun toString() = translation + override fun equals(other: Any?) = other is KeysSelectBoxEntry && other.name == this.name + override fun hashCode() = name.hashCode() + } + + companion object { + // Gdx.Input.Keys has a keyNames map internally that would serve, + // but it's private and only used for valueOf() - So we do the same here... + private val keyCodeMap = LinkedHashMap(200).apply { + for (code in 0..Input.Keys.MAX_KEYCODE) { + val name = Input.Keys.toString(code) ?: continue + put(name, code) + } + } + + val defaultExclusions = setOf( + Input.Keys.UNKNOWN, // Any key GLFW or Gdx fail to translate fire this - and we assign our own meaning + Input.Keys.ESCAPE, // Married to Android Back, we want to keep this closing the Options + Input.Keys.CONTROL_LEFT, // Captured to support control-letter combos + Input.Keys.CONTROL_RIGHT, + Input.Keys.PICTSYMBOLS, // Ugly toString and unknown who could have such a key + Input.Keys.SWITCH_CHARSET, + Input.Keys.SYM, + Input.Keys.PRINT_SCREEN, // Not captured by Gdx by default, these are handled by the OS + Input.Keys.VOLUME_UP, + Input.Keys.VOLUME_DOWN, + Input.Keys.MUTE, + ) + + fun getKeyNames(excludeKeys: Set): Sequence { + val keyCodeNames = keyCodeMap.asSequence() + .filterNot { it.value in excludeKeys } + .map { it.key } + val controlNames = ('A'..'Z').asSequence() + .map { "Ctrl-$it" } + return (keyCodeNames + controlNames) + .sortedWith( + compareBy { + when { + it.length == 1 -> 0 // Characters first + it.length in 2..3 && it[0] == 'F' && it[1].isDigit() -> + it.drop(1).toInt() // then F-Keys numerically + it.startsWith("Ctrl-") -> 100 // Then Ctrl-* + else -> 999 // Rest last + } + }.thenBy { it } // should be by translated, but - later + ) + } + + private fun getTranslationKey(keyName: String): String { + val noSpaces = keyName.replace(' ','-') + if (noSpaces.last().isLetterOrDigit()) return "Key-$noSpaces" + return "Key-${noSpaces.dropLast(1)}\"${noSpaces.last()}\"" + } + + @Suppress("unused") // TranslationFileWriter integration will be done later + fun getAllTranslationKeys() = getKeyNames(defaultExclusions).map { getTranslationKey(it) } + } + + private val normalStyle: SelectBoxStyle + private val defaultStyle: SelectBoxStyle + + init { + //TODO: + // * Dropdown doesn't listen to mouse wheel until after some focus setting click + // (like any other SelectBox, but here it's more noticeable) + + items = GdxArray(200).apply { + for (name in getKeyNames(excludeKeys)) + add(KeysSelectBoxEntry(name)) + } + setSelected(default) + + maxListCount = 12 // or else the dropdown will fill as much vertical space as it can, including upwards + + addListener(object : ChangeListener() { + override fun changed(event: ChangeEvent?, actor: Actor?) { + setColorForDefault() + changeCallback?.invoke() + } + }) + + // clone style to prevent bleeding our changes to other Widgets + normalStyle = SelectBoxStyle(style) + // Another instance of SelectBoxStyle for displaying whether the selection is the default one + defaultStyle = SelectBoxStyle(normalStyle) + defaultStyle.fontColor = Color.GRAY.cpy() + + setColorForDefault() + } + + fun setSelected(name: String) { + val newSelection = items.firstOrNull { it.name == name } + ?: return + selected = newSelection + } + + /** Update fontColor to show whether the selection is the default one (as grayed) */ + private fun setColorForDefault() { + // See Javadoc of underlying style - setStyle is needed to update visually + @Suppress("UsePropertyAccessSyntax") + setStyle(if (selected.name == default) defaultStyle else normalStyle) + } +} diff --git a/core/src/com/unciv/ui/popups/options/KeyBindingsTab.kt b/core/src/com/unciv/ui/popups/options/KeyBindingsTab.kt index c1c012c57b..1c16b18992 100644 --- a/core/src/com/unciv/ui/popups/options/KeyBindingsTab.kt +++ b/core/src/com/unciv/ui/popups/options/KeyBindingsTab.kt @@ -1,29 +1,21 @@ package com.unciv.ui.popups.options -import com.badlogic.gdx.Input -import com.badlogic.gdx.graphics.Color -import com.badlogic.gdx.scenes.scene2d.Actor -import com.badlogic.gdx.scenes.scene2d.InputEvent -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.scenes.scene2d.utils.ClickListener -import com.badlogic.gdx.utils.Align import com.unciv.GUI import com.unciv.models.ruleset.RulesetCache +import com.unciv.ui.components.KeyCapturingButton import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.KeyboardBinding import com.unciv.ui.components.KeyboardBindings +import com.unciv.ui.components.KeysSelectBox import com.unciv.ui.components.TabbedPager -import com.unciv.ui.components.UncivTextField -import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.extensions.toLabel -import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen import com.unciv.ui.screens.civilopediascreen.FormattedLine import com.unciv.ui.screens.civilopediascreen.MarkupRenderer + class KeyBindingsTab( optionsPopup: OptionsPopup, labelWidth: Float @@ -75,114 +67,56 @@ class KeyBindingsTab( save() } - /** A button that captures keyboard keys and reports them through [onKeyHit] */ - class KeyCapturingButton( - private val onKeyHit: (keyCode: Int, control: Boolean) -> Unit - ) : ImageButton(getStyle()) { - companion object { - private const val buttonSize = 36f - private const val buttonImage = "OtherIcons/Keyboard" - private val controlKeys = setOf(Input.Keys.CONTROL_LEFT, Input.Keys.CONTROL_RIGHT) - - private fun getStyle() = ImageButtonStyle().apply { - val image = ImageGetter.getDrawable(buttonImage) - imageUp = image - imageOver = image.tint(Color.LIME) - } - } - - private var savedFocus: Actor? = null - - init { - setSize(buttonSize, buttonSize) - addTooltip("Hit the desired key now", 18f, targetAlign = Align.bottomRight) - addListener(ButtonListener(this)) - } - - class ButtonListener(private val myButton: KeyCapturingButton) : ClickListener() { - private var controlDown = false - - override fun enter(event: InputEvent?, x: Float, y: Float, pointer: Int, fromActor: Actor?) { - if (myButton.stage == null) return - myButton.savedFocus = myButton.stage.keyboardFocus - myButton.stage.keyboardFocus = myButton - } - - override fun exit(event: InputEvent?, x: Float, y: Float, pointer: Int, toActor: Actor?) { - if (myButton.stage == null) return - myButton.stage.keyboardFocus = myButton.savedFocus - myButton.savedFocus = null - } - - override fun keyDown(event: InputEvent?, keycode: Int): Boolean { - if (keycode == Input.Keys.ESCAPE) return false - if (keycode in controlKeys) { - controlDown = true - } else { - myButton.onKeyHit(keycode, controlDown) - } - return true - } - - override fun keyUp(event: InputEvent?, keycode: Int): Boolean { - if (keycode == Input.Keys.ESCAPE) return false - if (keycode in controlKeys) - controlDown = false - return true - } - } - } - class KeyboardBindingWidget( /** The specific binding to edit */ private val binding: KeyboardBinding ) : Table(BaseScreen.skin) { - private val textField: TextField = - UncivTextField.create(binding.defaultKey.toString()) { focused -> - if (!focused) validateText() - } + private val selectBox = KeysSelectBox(binding.defaultKey.toString()) { + validateSelection() + } private val button = KeyCapturingButton { code, control -> + selectBox.hideScrollPane() boundKey = if (control) KeyCharAndCode.ctrlFromCode(code) else KeyCharAndCode(code) - resetText() + resetSelection() } - private var boundKey: KeyCharAndCode? = null + private var boundKey: KeyCharAndCode = binding.defaultKey init { pad(0f) defaults().pad(0f) - textField.setScale(0.1f) - add(textField) - addActor(button) + add(selectBox) + add(button).size(36f).padLeft(2f) } + /** Get the (untranslated) key name selected by the Widget */ + // we let the KeysSelectBox handle undesired mappings val text: String - get() = textField.text + get() = selectBox.selected.name + /** Update control to show current binding */ fun update(keyBindings: KeyboardBindings) { boundKey = keyBindings[binding] - resetText() - - // Since the TextField itself is temporary, this is only quick & dirty - button.setPosition(textField.width - (textField.height - button.height) / 2, textField.height / 2, Align.right) + resetSelection() } - private fun validateText() { + /** Set boundKey from selectBox */ + private fun validateSelection() { val value = text val parsedKey = KeyCharAndCode.parse(value) if (parsedKey == KeyCharAndCode.UNKNOWN) { - resetText() + resetSelection() } else { boundKey = parsedKey } } - private fun resetText() { - if (boundKey == binding.defaultKey) boundKey = null - textField.text = boundKey?.toString() ?: "" + /** Set selectBox from boundKey */ + private fun resetSelection() { + selectBox.setSelected(boundKey.toString()) } } } diff --git a/core/src/com/unciv/ui/popups/options/OptionsPopup.kt b/core/src/com/unciv/ui/popups/options/OptionsPopup.kt index 7288b1e14c..a4fe82930b 100644 --- a/core/src/com/unciv/ui/popups/options/OptionsPopup.kt +++ b/core/src/com/unciv/ui/popups/options/OptionsPopup.kt @@ -16,7 +16,6 @@ import com.unciv.ui.popups.Popup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen import com.unciv.ui.screens.worldscreen.WorldScreen -import com.unciv.utils.DebugUtils import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.withGLContext import kotlin.reflect.KMutableProperty0