complete refactor

This commit is contained in:
David Griswold 2025-11-15 19:45:37 +03:00 committed by OpenSauce
parent 371d747df7
commit a438094d44
8 changed files with 251 additions and 239 deletions

View File

@ -35,8 +35,6 @@ class InputBindingSetting(
.apply() .apply()
} }
private var key: String = ""
/** /**
* Returns true if this key is for the 3DS Circle Pad * Returns true if this key is for the 3DS Circle Pad
*/ */
@ -81,6 +79,36 @@ class InputBindingSetting(
else -> false else -> false
} }
/**
* Returns true if this is an up or down dpad button
*/
fun isVerticalButton(): Boolean =
when (abstractSetting.key) {
Settings.KEY_BUTTON_DOWN,
Settings.KEY_BUTTON_UP -> true
else -> false
}
fun isVerticalAxis(): Boolean {
return abstractSetting.key ==
Settings.KEY_DPAD_AXIS_VERTICAL
}
fun isHorizontalAxis(): Boolean {
return abstractSetting.key ==
Settings.KEY_DPAD_AXIS_HORIZONTAL
}
fun isHorizontalButton(): Boolean =
when (abstractSetting.key) {
Settings.KEY_BUTTON_LEFT,
Settings.KEY_BUTTON_RIGHT -> true
else -> false
}
/** /**
* Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real * Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real
* triggers on the 3DS, but we support them as such on a physical gamepad. * triggers on the 3DS, but we support them as such on a physical gamepad.
@ -233,29 +261,6 @@ class InputBindingSetting(
value = uiString value = uiString
} }
/**
* Stores the provided key input setting as an Android preference.
* Only gets applied when apply(); is called.
*
* @param keyEvent KeyEvent of this key press.
*/
fun onKeyInputDeferred(keyEvent: KeyEvent) {
if (!isButtonMappingSupported()) {
Toast.makeText(context, R.string.input_message_analog_only, Toast.LENGTH_LONG).show()
return
}
key = getInputButtonKey(keyEvent.keyCode)
val uiString = "${keyEvent.device.name}: Button ${keyEvent.keyCode}"
value = uiString
}
/**
* Stores the provided key input setting as an Android preference.
*/
fun applyMapping() {
writeButtonMapping(key)
}
/** /**
* Saves the provided motion input setting as an Android preference. * Saves the provided motion input setting as an Android preference.
* *

View File

@ -4,40 +4,85 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.citra.citra_emu.R import org.citra.citra_emu.R
import org.citra.citra_emu.databinding.DialogControllerQuickConfigBinding import org.citra.citra_emu.databinding.DialogControllerQuickConfigBinding
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import kotlin.math.abs import org.citra.citra_emu.features.settings.utils.InputBindingBase
class ControllerQuickConfigDialog( class ControllerQuickConfigDialog(
private var context: Context, private var context: Context,
buttons: ArrayList<List<String>>, buttons: ArrayList<List<String>>,
titles: ArrayList<List<Int>>, titles: ArrayList<List<Int>>,
private var preferences: SharedPreferences private var preferences: SharedPreferences,
adapter: SettingsAdapter
) { ) {
private var isWaiting = false;
private var index = 0 private var index = 0
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
val quickConfigBinding = DialogControllerQuickConfigBinding.inflate(inflater) private lateinit var quickConfigBinding: DialogControllerQuickConfigBinding
var dialog: AlertDialog? = null var dialog: AlertDialog? = null
private var boundVerticalDpadAxis = false
private var boundHorizontalDpadAxis = false
var allButtons = arrayListOf<String>() var allButtons = arrayListOf<String>()
var allTitles = arrayListOf<Int>() var allTitles = arrayListOf<Int>()
private val inputHandler = object : InputBindingBase() {
private val AXIS_WAIT_TIME = 400L
private val BUTTON_WAIT_TIME = 100L
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
override fun onAxisCaptured() {
boundHorizontalDpadAxis =
boundHorizontalDpadAxis || setting != null && setting!!.isVerticalAxis()
boundVerticalDpadAxis =
boundVerticalDpadAxis || setting != null && setting!!.isHorizontalAxis()
onInputCaptured(AXIS_WAIT_TIME)
}
override fun onButtonCaptured() {
onInputCaptured(BUTTON_WAIT_TIME)
}
private fun onInputCaptured(waitTime: Long) {
if (isWaiting) {
return
}
quickConfigBinding.root.cancelPendingInputEvents()
index++
setting?.let { settingsList.add(it) }
isWaiting = true
quickConfigBinding.currentMappingTitle.text = context.getString(R.string.controller_quick_config_wait)
// the changed item after each setting is one more than the index, since the Quick Configure button is position 1
adapter.notifyItemChanged(index + 1)
// clear listeners during the waiting period, they will get reset once ready for input
dialog?.setOnKeyListener(null)
quickConfigBinding.root.setOnGenericMotionListener(null)
// Wait before preparing the next input
handler.postDelayed({
isWaiting = false
prepareUIforIndex()
}, waitTime)
}
override fun getCurrentSetting() = setting
}
init { init {
buttons.forEach {group -> buttons.forEach { group ->
group.forEach {button -> group.forEach { button ->
allButtons.add(button) allButtons.add(button)
} }
} }
titles.forEach {group -> titles.forEach { group ->
group.forEach {title -> group.forEach { title ->
allTitles.add(title) allTitles.add(title)
} }
} }
@ -45,10 +90,11 @@ class ControllerQuickConfigDialog(
fun show() { fun show() {
val builder: AlertDialog.Builder = AlertDialog.Builder(context) val builder: AlertDialog.Builder = AlertDialog.Builder(context)
quickConfigBinding = DialogControllerQuickConfigBinding.inflate(inflater)
builder builder
.setView(quickConfigBinding.root) .setView(quickConfigBinding.root)
.setTitle(context.getString(R.string.controller_quick_config)) .setTitle(context.getString(R.string.controller_quick_config))
.setPositiveButton(context.getString(R.string.next)) {_,_ -> } .setPositiveButton(context.getString(R.string.next)) { _, _ -> }
.setNegativeButton(context.getString(R.string.close)) { dialog, which -> .setNegativeButton(context.getString(R.string.close)) { dialog, which ->
dialog.dismiss() dialog.dismiss()
} }
@ -56,36 +102,63 @@ class ControllerQuickConfigDialog(
dialog = builder.create() dialog = builder.create()
dialog?.show() dialog?.show()
dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } quickConfigBinding.root.requestFocus()
quickConfigBinding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } quickConfigBinding.root.setOnFocusChangeListener { v, hasFocus ->
if (!hasFocus) v.requestFocus()
}
// Prepare the first element // Prepare the first element
prepareUIforIndex(index) prepareUIforIndex()
val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE) val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE)
nextButton?.setOnClickListener { nextButton?.setOnClickListener {
// Skip to next: if (setting != null) setting!!.removeOldMapping()
prepareUIforIndex(index++) index++
prepareUIforIndex()
} }
} }
private fun prepareUIforIndex(i: Int) { private fun prepareUIforIndex() {
if (allButtons.size-1 < i) { if (index >= allButtons.size) {
settingsList.forEach { it.applyMapping() }
dialog?.dismiss() dialog?.dismiss()
return return
} }
setting = InputBindingSetting(
InputBindingSetting.getInputObject(allButtons[index], preferences),
allTitles[index]
)
// skip the dpad buttons if the corresponding axis is mapped
while (setting != null && (
setting!!.isVerticalButton() && boundVerticalDpadAxis ||
setting!!.isHorizontalButton() && boundHorizontalDpadAxis)
) {
index++
if (index >= allButtons.size) {
dialog?.dismiss()
return
}
setting = InputBindingSetting(
InputBindingSetting.getInputObject(
allButtons[index],
preferences
), allTitles[index]
)
if (index>0) { }
// show the previous key, if this isn't the first key
if (index > 0) {
quickConfigBinding.lastMappingIcon.visibility = View.VISIBLE quickConfigBinding.lastMappingIcon.visibility = View.VISIBLE
quickConfigBinding.lastMappingDescription.visibility = View.VISIBLE quickConfigBinding.lastMappingDescription.visibility = View.VISIBLE
} }
val currentButton = allButtons[i] // change the button layout for the last button
val currentTitleInt = allTitles[i] if (index == allButtons.size - 1) {
dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text =
val button = InputBindingSetting.getInputObject(currentButton, preferences) context.getString(R.string.finish)
dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
}
// set all the icons and text
var lastTitle = setting?.value ?: "" var lastTitle = setting?.value ?: ""
if (lastTitle.isBlank()) { if (lastTitle.isBlank()) {
lastTitle = context.getString(R.string.unassigned) lastTitle = context.getString(R.string.unassigned)
@ -93,16 +166,23 @@ class ControllerQuickConfigDialog(
quickConfigBinding.lastMappingDescription.text = lastTitle quickConfigBinding.lastMappingDescription.text = lastTitle
quickConfigBinding.lastMappingIcon.setImageDrawable(quickConfigBinding.currentMappingIcon.drawable) quickConfigBinding.lastMappingIcon.setImageDrawable(quickConfigBinding.currentMappingIcon.drawable)
setting = InputBindingSetting(button, currentTitleInt)
quickConfigBinding.currentMappingTitle.text = calculateTitle() quickConfigBinding.currentMappingTitle.text = calculateTitle()
quickConfigBinding.currentMappingDescription.text = setting?.value quickConfigBinding.currentMappingDescription.text = setting?.value
quickConfigBinding.currentMappingIcon.setImageDrawable(getIcon()) quickConfigBinding.currentMappingIcon.setImageDrawable(getIcon())
if (allButtons.size-1 < index) { // reset all the handlers
dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text = if (setting!!.isButtonMappingSupported()) {
context.getString(R.string.finish) dialog?.setOnKeyListener { _, _, event -> inputHandler.onKeyEvent(event) }
dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
} }
if (setting!!.isAxisMappingSupported()) {
quickConfigBinding.root.setOnGenericMotionListener { _, event ->
inputHandler.onMotionEvent(
event
)
}
}
inputHandler.reset()
} }
private fun calculateTitle(): String { private fun calculateTitle(): String {
@ -140,98 +220,7 @@ class ControllerQuickConfigDialog(
return ContextCompat.getDrawable(context, id) return ContextCompat.getDrawable(context, id)
} }
private val previousValues = ArrayList<Float>()
private var prevDeviceId = 0
private var waitingForEvent = true
private var setting: InputBindingSetting? = null private var setting: InputBindingSetting? = null
private var debounceTimestamp = System.currentTimeMillis()
private var settingsList = arrayListOf<InputBindingSetting>() private var settingsList = arrayListOf<InputBindingSetting>()
private fun onKeyEvent(event: KeyEvent): Boolean {
return when (event.action) {
KeyEvent.ACTION_UP -> {
if (System.currentTimeMillis()-debounceTimestamp < DEBOUNCE_TIMER) {
return true
}
debounceTimestamp = System.currentTimeMillis()
index++
setting?.let {
it.onKeyInputDeferred(event)
settingsList.add(it)
}
prepareUIforIndex(index)
// Even if we ignore the key, we still consume it. Thus return true regardless.
true
}
else -> false
}
}
private fun onMotionEvent(event: MotionEvent): Boolean {
if ((event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) ||
event.action != MotionEvent.ACTION_MOVE) return false
val input = event.device
val motionRanges = input.motionRanges
if (input.id != prevDeviceId) {
previousValues.clear()
}
prevDeviceId = input.id
val firstEvent = previousValues.isEmpty()
var numMovedAxis = 0
var axisMoveValue = 0.0f
var lastMovedRange: InputDevice.MotionRange? = null
var lastMovedDir = '?'
if (waitingForEvent) {
for (i in motionRanges.indices) {
val range = motionRanges[i]
val axis = range.axis
val origValue = event.getAxisValue(axis)
if (firstEvent) {
previousValues.add(origValue)
} else {
val previousValue = previousValues[i]
// Only handle the axes that are not neutral (more than 0.5)
// but ignore any axis that has a constant value (e.g. always 1)
if (abs(origValue) > 0.5f && origValue != previousValue) {
// It is common to have multiple axes with the same physical input. For example,
// shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE.
// To handle this, we ignore an axis motion that's the exact same as a motion
// we already saw. This way, we ignore axes with two names, but catch the case
// where a joystick is moved in two directions.
// ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html
if (origValue != axisMoveValue) {
axisMoveValue = origValue
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (origValue < 0.0f) '-' else '+'
}
} else if (abs(origValue) < 0.25f && abs(previousValue) > 0.75f) {
// Special case for d-pads (axis value jumps between 0 and 1 without any values
// in between). Without this, the user would need to press the d-pad twice
// due to the first press being caught by the "if (firstEvent)" case further up.
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (previousValue < 0.0f) '-' else '+'
}
}
previousValues[i] = origValue
}
// If only one axis moved, that's the winner.
if (numMovedAxis == 1) {
waitingForEvent = false
setting?.onMotionInput(input, lastMovedRange!!, lastMovedDir)
}
}
return true
}
companion object {
private const val DEBOUNCE_TIMER = 100
}
} }

View File

@ -617,7 +617,8 @@ class SettingsAdapter(
Settings.triggerTitles Settings.triggerTitles
) )
ControllerQuickConfigDialog(context, buttons, titles, PermissionsHandler.preferences).show() ControllerQuickConfigDialog(context, buttons, titles, PermissionsHandler.preferences, this).show()
} }
fun closeDialog() { fun closeDialog() {

View File

@ -764,8 +764,8 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
add( add(
RunnableSetting( RunnableSetting(
R.string.controller_quick_config, R.string.controller_quick_config,
0, R.string.controller_quick_config_description,
false, true,
0, 0,
{ settingsAdapter.onClickControllerQuickConfig() } { settingsAdapter.onClickControllerQuickConfig() }
) )

View File

@ -0,0 +1,87 @@
package org.citra.citra_emu.features.settings.utils
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import kotlin.math.abs
abstract class InputBindingBase {
private val previousValues = ArrayList<Float>()
private var prevDeviceId = 0
private var waitingForEvent = true
abstract fun onButtonCaptured()
abstract fun onAxisCaptured()
abstract fun getCurrentSetting(): InputBindingSetting?
fun reset() {
previousValues.clear()
prevDeviceId = 0
waitingForEvent = true
}
fun onMotionEvent(event: MotionEvent): Boolean {
if ((event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) ||
event.action != MotionEvent.ACTION_MOVE) return false
val input = event.device
val motionRanges = input.motionRanges
if (input.id != prevDeviceId) {
previousValues.clear()
}
prevDeviceId = input.id
val firstEvent = previousValues.isEmpty()
var numMovedAxis = 0
var axisMoveValue = 0.0f
var lastMovedRange: InputDevice.MotionRange? = null
var lastMovedDir = '?'
if (waitingForEvent) {
for (i in motionRanges.indices) {
val range = motionRanges[i]
val axis = range.axis
val origValue = event.getAxisValue(axis)
if (firstEvent) {
previousValues.add(origValue)
} else {
val previousValue = previousValues[i]
if (abs(origValue) > 0.5f && origValue != previousValue) {
if (origValue != axisMoveValue) {
axisMoveValue = origValue
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (origValue < 0.0f) '-' else '+'
}
} else if (abs(origValue) < 0.25f && abs(previousValue) > 0.75f) {
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (previousValue < 0.0f) '-' else '+'
}
}
previousValues[i] = origValue
}
if (numMovedAxis == 1) {
waitingForEvent = false
getCurrentSetting()?.onMotionInput(input, lastMovedRange!!, lastMovedDir)
onAxisCaptured()
}
}
return true
}
fun onKeyEvent(event: KeyEvent): Boolean {
return when (event.action) {
KeyEvent.ACTION_UP -> {
getCurrentSetting()?.onKeyInput(event)
onButtonCaptured()
true
}
else -> false
}
}
}

View File

@ -6,10 +6,7 @@ package org.citra.citra_emu.fragments
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -17,10 +14,21 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.citra.citra_emu.R import org.citra.citra_emu.R
import org.citra.citra_emu.databinding.DialogInputBinding import org.citra.citra_emu.databinding.DialogInputBinding
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.utils.Log import org.citra.citra_emu.features.settings.utils.InputBindingBase
import kotlin.math.abs
class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() { class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
private val inputHandler = object : InputBindingBase() {
override fun onButtonCaptured() {
dismiss()
}
override fun onAxisCaptured() {
dismiss()
}
override fun getCurrentSetting() = setting
}
private var _binding: DialogInputBinding? = null private var _binding: DialogInputBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@ -28,10 +36,6 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
private var onCancel: (() -> Unit)? = null private var onCancel: (() -> Unit)? = null
private var onDismiss: (() -> Unit)? = null private var onDismiss: (() -> Unit)? = null
private val previousValues = ArrayList<Float>()
private var prevDeviceId = 0
private var waitingForEvent = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (setting == null) { if (setting == null) {
@ -57,10 +61,10 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
view.requestFocus() view.requestFocus()
view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() }
if (setting!!.isButtonMappingSupported()) { if (setting!!.isButtonMappingSupported()) {
dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } dialog?.setOnKeyListener { _, _, event -> inputHandler.onKeyEvent(event) }
} }
if (setting!!.isAxisMappingSupported()) { if (setting!!.isAxisMappingSupported()) {
binding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } binding.root.setOnGenericMotionListener { _, event -> inputHandler.onMotionEvent(event) }
} }
val inputTypeId = when { val inputTypeId = when {
@ -108,85 +112,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
onDismiss?.invoke() onDismiss?.invoke()
} }
private fun onKeyEvent(event: KeyEvent): Boolean {
Log.debug("[MotionBottomSheetDialogFragment] Received key event: " + event.action)
return when (event.action) {
KeyEvent.ACTION_UP -> {
setting?.onKeyInput(event)
dismiss()
// Even if we ignore the key, we still consume it. Thus return true regardless.
true
}
else -> false
}
}
private fun onMotionEvent(event: MotionEvent): Boolean {
Log.debug("[MotionBottomSheetDialogFragment] Received motion event: " + event.action)
if (event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) return false
if (event.action != MotionEvent.ACTION_MOVE) return false
val input = event.device
val motionRanges = input.motionRanges
if (input.id != prevDeviceId) {
previousValues.clear()
}
prevDeviceId = input.id
val firstEvent = previousValues.isEmpty()
var numMovedAxis = 0
var axisMoveValue = 0.0f
var lastMovedRange: InputDevice.MotionRange? = null
var lastMovedDir = '?'
if (waitingForEvent) {
for (i in motionRanges.indices) {
val range = motionRanges[i]
val axis = range.axis
val origValue = event.getAxisValue(axis)
if (firstEvent) {
previousValues.add(origValue)
} else {
val previousValue = previousValues[i]
// Only handle the axes that are not neutral (more than 0.5)
// but ignore any axis that has a constant value (e.g. always 1)
if (abs(origValue) > 0.5f && origValue != previousValue) {
// It is common to have multiple axes with the same physical input. For example,
// shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE.
// To handle this, we ignore an axis motion that's the exact same as a motion
// we already saw. This way, we ignore axes with two names, but catch the case
// where a joystick is moved in two directions.
// ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html
if (origValue != axisMoveValue) {
axisMoveValue = origValue
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (origValue < 0.0f) '-' else '+'
}
} else if (abs(origValue) < 0.25f && abs(previousValue) > 0.75f) {
// Special case for d-pads (axis value jumps between 0 and 1 without any values
// in between). Without this, the user would need to press the d-pad twice
// due to the first press being caught by the "if (firstEvent)" case further up.
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (previousValue < 0.0f) '-' else '+'
}
}
previousValues[i] = origValue
}
// If only one axis moved, that's the winner.
if (numMovedAxis == 1) {
waitingForEvent = false
setting?.onMotionInput(input, lastMovedRange!!, lastMovedDir)
dismiss()
}
}
return true
}
companion object { companion object {
const val TAG = "MotionBottomSheetDialogFragment" const val TAG = "MotionBottomSheetDialogFragment"

View File

@ -3,7 +3,9 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linearLayout" android:id="@+id/linearLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="true">
<ImageView <ImageView
android:id="@+id/lastMappingIcon" android:id="@+id/lastMappingIcon"

View File

@ -126,6 +126,8 @@
<string name="controller_axis_vertical">Up/Down Axis</string> <string name="controller_axis_vertical">Up/Down Axis</string>
<string name="controller_axis_horizontal">Left/Right Axis</string> <string name="controller_axis_horizontal">Left/Right Axis</string>
<string name="controller_quick_config">Quick Configure</string> <string name="controller_quick_config">Quick Configure</string>
<string name="controller_quick_config_description">Quickly configure all input buttons (not including hotkeys)</string>
<string name="controller_quick_config_wait">Please Wait</string>
<string name="direction_up">Up</string> <string name="direction_up">Up</string>
<string name="direction_down">Down</string> <string name="direction_down">Down</string>
<string name="direction_left">Left</string> <string name="direction_left">Left</string>