Key bindings Step 3 - Better UI (#8891)

* Configurable key bindings - better Widget step 2

* Configurable key bindings - step 2 updated help
This commit is contained in:
SomeTroglodyte
2023-03-15 08:05:50 +01:00
committed by GitHub
parent f1c891252e
commit aab975a61b
5 changed files with 234 additions and 93 deletions

View File

@ -401,16 +401,15 @@
{"text":"Currently, there are no checks to prevent conflicting assignments.","starred":true}, {"text":"Currently, there are no checks to prevent conflicting assignments.","starred":true},
{}, {},
{"text":"Using the Keys page","header":3}, {"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}, {"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":"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":"Alternatively, you can select the key in the dropdown."},
{"text":"To reset a binding to its default, simply clear the text field."},
{}, {},
{"text":"Bindings mapped to their default keys are displayed in gray, those reassigned by you in white."}, {"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"} {"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 // "uniques": ["Will not be displayed in Civilopedia"] // would prevent use for help link
} }
] ]

View File

@ -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
}
}
}

View File

@ -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<Int> = defaultExclusions,
private val changeCallback: (() -> Unit)? = null
) : SelectBox<KeysSelectBox.KeysSelectBoxEntry>(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<String, Int>(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<Int>): Sequence<String> {
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<String> {
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<KeysSelectBoxEntry>(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)
}
}

View File

@ -1,29 +1,21 @@
package com.unciv.ui.popups.options 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.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.GUI
import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.components.KeyCapturingButton
import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.KeyCharAndCode
import com.unciv.ui.components.KeyboardBinding import com.unciv.ui.components.KeyboardBinding
import com.unciv.ui.components.KeyboardBindings import com.unciv.ui.components.KeyboardBindings
import com.unciv.ui.components.KeysSelectBox
import com.unciv.ui.components.TabbedPager 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.components.extensions.toLabel
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
import com.unciv.ui.screens.civilopediascreen.FormattedLine import com.unciv.ui.screens.civilopediascreen.FormattedLine
import com.unciv.ui.screens.civilopediascreen.MarkupRenderer import com.unciv.ui.screens.civilopediascreen.MarkupRenderer
class KeyBindingsTab( class KeyBindingsTab(
optionsPopup: OptionsPopup, optionsPopup: OptionsPopup,
labelWidth: Float labelWidth: Float
@ -75,114 +67,56 @@ class KeyBindingsTab(
save() 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( class KeyboardBindingWidget(
/** The specific binding to edit */ /** The specific binding to edit */
private val binding: KeyboardBinding private val binding: KeyboardBinding
) : Table(BaseScreen.skin) { ) : Table(BaseScreen.skin) {
private val textField: TextField = private val selectBox = KeysSelectBox(binding.defaultKey.toString()) {
UncivTextField.create(binding.defaultKey.toString()) { focused -> validateSelection()
if (!focused) validateText() }
}
private val button = KeyCapturingButton { code, control -> private val button = KeyCapturingButton { code, control ->
selectBox.hideScrollPane()
boundKey = if (control) boundKey = if (control)
KeyCharAndCode.ctrlFromCode(code) KeyCharAndCode.ctrlFromCode(code)
else KeyCharAndCode(code) else KeyCharAndCode(code)
resetText() resetSelection()
} }
private var boundKey: KeyCharAndCode? = null private var boundKey: KeyCharAndCode = binding.defaultKey
init { init {
pad(0f) pad(0f)
defaults().pad(0f) defaults().pad(0f)
textField.setScale(0.1f) add(selectBox)
add(textField) add(button).size(36f).padLeft(2f)
addActor(button)
} }
/** Get the (untranslated) key name selected by the Widget */
// we let the KeysSelectBox handle undesired mappings
val text: String val text: String
get() = textField.text get() = selectBox.selected.name
/** Update control to show current binding */
fun update(keyBindings: KeyboardBindings) { fun update(keyBindings: KeyboardBindings) {
boundKey = keyBindings[binding] boundKey = keyBindings[binding]
resetText() resetSelection()
// 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)
} }
private fun validateText() { /** Set boundKey from selectBox */
private fun validateSelection() {
val value = text val value = text
val parsedKey = KeyCharAndCode.parse(value) val parsedKey = KeyCharAndCode.parse(value)
if (parsedKey == KeyCharAndCode.UNKNOWN) { if (parsedKey == KeyCharAndCode.UNKNOWN) {
resetText() resetSelection()
} else { } else {
boundKey = parsedKey boundKey = parsedKey
} }
} }
private fun resetText() { /** Set selectBox from boundKey */
if (boundKey == binding.defaultKey) boundKey = null private fun resetSelection() {
textField.text = boundKey?.toString() ?: "" selectBox.setSelected(boundKey.toString())
} }
} }
} }

View File

@ -16,7 +16,6 @@ import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
import com.unciv.ui.screens.worldscreen.WorldScreen import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.utils.DebugUtils
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.concurrency.withGLContext
import kotlin.reflect.KMutableProperty0 import kotlin.reflect.KMutableProperty0