From a438094d4466ba307159cd0e34ada6db136b6bc4 Mon Sep 17 00:00:00 2001 From: David Griswold Date: Sat, 15 Nov 2025 19:45:37 +0300 Subject: [PATCH] complete refactor --- .../model/view/InputBindingSetting.kt | 55 +++-- .../ui/ControllerQuickConfigDialog.kt | 231 +++++++++--------- .../features/settings/ui/SettingsAdapter.kt | 3 +- .../settings/ui/SettingsFragmentPresenter.kt | 4 +- .../settings/utils/InputBindingBase.kt | 87 +++++++ .../MotionBottomSheetDialogFragment.kt | 104 ++------ .../layout/dialog_controller_quick_config.xml | 4 +- .../app/src/main/res/values/strings.xml | 2 + 8 files changed, 251 insertions(+), 239 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/InputBindingBase.kt diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt index f242e77aa..b760287e6 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt @@ -35,8 +35,6 @@ class InputBindingSetting( .apply() } - private var key: String = "" - /** * Returns true if this key is for the 3DS Circle Pad */ @@ -81,6 +79,36 @@ class InputBindingSetting( 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 * triggers on the 3DS, but we support them as such on a physical gamepad. @@ -233,29 +261,6 @@ class InputBindingSetting( 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. * diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerQuickConfigDialog.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerQuickConfigDialog.kt index 0200ad8bc..cad28ea3a 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerQuickConfigDialog.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerQuickConfigDialog.kt @@ -4,40 +4,85 @@ import android.app.AlertDialog import android.content.Context import android.content.SharedPreferences import android.graphics.drawable.Drawable -import android.view.InputDevice -import android.view.KeyEvent import android.view.LayoutInflater -import android.view.MotionEvent import android.view.View import androidx.core.content.ContextCompat import org.citra.citra_emu.R import org.citra.citra_emu.databinding.DialogControllerQuickConfigBinding 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( private var context: Context, buttons: ArrayList>, titles: ArrayList>, - private var preferences: SharedPreferences + private var preferences: SharedPreferences, + adapter: SettingsAdapter ) { + private var isWaiting = false; private var index = 0 val inflater = LayoutInflater.from(context) - val quickConfigBinding = DialogControllerQuickConfigBinding.inflate(inflater) + private lateinit var quickConfigBinding: DialogControllerQuickConfigBinding var dialog: AlertDialog? = null + private var boundVerticalDpadAxis = false + private var boundHorizontalDpadAxis = false var allButtons = arrayListOf() var allTitles = arrayListOf() + 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 { - buttons.forEach {group -> - group.forEach {button -> + buttons.forEach { group -> + group.forEach { button -> allButtons.add(button) } } - titles.forEach {group -> - group.forEach {title -> + titles.forEach { group -> + group.forEach { title -> allTitles.add(title) } } @@ -45,10 +90,11 @@ class ControllerQuickConfigDialog( fun show() { val builder: AlertDialog.Builder = AlertDialog.Builder(context) + quickConfigBinding = DialogControllerQuickConfigBinding.inflate(inflater) builder .setView(quickConfigBinding.root) .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 -> dialog.dismiss() } @@ -56,36 +102,63 @@ class ControllerQuickConfigDialog( dialog = builder.create() dialog?.show() - dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } - quickConfigBinding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } + quickConfigBinding.root.requestFocus() + quickConfigBinding.root.setOnFocusChangeListener { v, hasFocus -> + if (!hasFocus) v.requestFocus() + } // Prepare the first element - prepareUIforIndex(index) + prepareUIforIndex() val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE) nextButton?.setOnClickListener { - // Skip to next: - prepareUIforIndex(index++) + if (setting != null) setting!!.removeOldMapping() + index++ + prepareUIforIndex() } } - private fun prepareUIforIndex(i: Int) { - if (allButtons.size-1 < i) { - settingsList.forEach { it.applyMapping() } + private fun prepareUIforIndex() { + if (index >= allButtons.size) { dialog?.dismiss() 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.lastMappingDescription.visibility = View.VISIBLE } - val currentButton = allButtons[i] - val currentTitleInt = allTitles[i] - - val button = InputBindingSetting.getInputObject(currentButton, preferences) + // change the button layout for the last button + if (index == allButtons.size - 1) { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text = + context.getString(R.string.finish) + dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE + } + // set all the icons and text var lastTitle = setting?.value ?: "" if (lastTitle.isBlank()) { lastTitle = context.getString(R.string.unassigned) @@ -93,16 +166,23 @@ class ControllerQuickConfigDialog( quickConfigBinding.lastMappingDescription.text = lastTitle quickConfigBinding.lastMappingIcon.setImageDrawable(quickConfigBinding.currentMappingIcon.drawable) - setting = InputBindingSetting(button, currentTitleInt) quickConfigBinding.currentMappingTitle.text = calculateTitle() quickConfigBinding.currentMappingDescription.text = setting?.value quickConfigBinding.currentMappingIcon.setImageDrawable(getIcon()) - if (allButtons.size-1 < index) { - dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text = - context.getString(R.string.finish) - dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE + // reset all the handlers + if (setting!!.isButtonMappingSupported()) { + dialog?.setOnKeyListener { _, _, event -> inputHandler.onKeyEvent(event) } } + if (setting!!.isAxisMappingSupported()) { + quickConfigBinding.root.setOnGenericMotionListener { _, event -> + inputHandler.onMotionEvent( + event + ) + } + } + inputHandler.reset() + } private fun calculateTitle(): String { @@ -140,98 +220,7 @@ class ControllerQuickConfigDialog( return ContextCompat.getDrawable(context, id) } - private val previousValues = ArrayList() - private var prevDeviceId = 0 - private var waitingForEvent = true private var setting: InputBindingSetting? = null - private var debounceTimestamp = System.currentTimeMillis() private var settingsList = arrayListOf() - - 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 - } } \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt index b4ac3c3dc..cc53307b3 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt @@ -617,7 +617,8 @@ class SettingsAdapter( Settings.triggerTitles ) - ControllerQuickConfigDialog(context, buttons, titles, PermissionsHandler.preferences).show() + ControllerQuickConfigDialog(context, buttons, titles, PermissionsHandler.preferences, this).show() + } fun closeDialog() { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt index d90fff4c9..b162c3c3f 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -764,8 +764,8 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) add( RunnableSetting( R.string.controller_quick_config, - 0, - false, + R.string.controller_quick_config_description, + true, 0, { settingsAdapter.onClickControllerQuickConfig() } ) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/InputBindingBase.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/InputBindingBase.kt new file mode 100644 index 000000000..5a1cfefb5 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/InputBindingBase.kt @@ -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() + 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 + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/MotionBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MotionBottomSheetDialogFragment.kt index cf42bed12..c23c05a81 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/MotionBottomSheetDialogFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MotionBottomSheetDialogFragment.kt @@ -6,10 +6,7 @@ package org.citra.citra_emu.fragments import android.content.DialogInterface import android.os.Bundle -import android.view.InputDevice -import android.view.KeyEvent import android.view.LayoutInflater -import android.view.MotionEvent import android.view.View import android.view.ViewGroup 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.databinding.DialogInputBinding import org.citra.citra_emu.features.settings.model.view.InputBindingSetting -import org.citra.citra_emu.utils.Log -import kotlin.math.abs +import org.citra.citra_emu.features.settings.utils.InputBindingBase 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 val binding get() = _binding!! @@ -28,10 +36,6 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() { private var onCancel: (() -> Unit)? = null private var onDismiss: (() -> Unit)? = null - private val previousValues = ArrayList() - private var prevDeviceId = 0 - private var waitingForEvent = true - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (setting == null) { @@ -57,10 +61,10 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() { view.requestFocus() view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } if (setting!!.isButtonMappingSupported()) { - dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } + dialog?.setOnKeyListener { _, _, event -> inputHandler.onKeyEvent(event) } } if (setting!!.isAxisMappingSupported()) { - binding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } + binding.root.setOnGenericMotionListener { _, event -> inputHandler.onMotionEvent(event) } } val inputTypeId = when { @@ -108,85 +112,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() { 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 { const val TAG = "MotionBottomSheetDialogFragment" diff --git a/src/android/app/src/main/res/layout/dialog_controller_quick_config.xml b/src/android/app/src/main/res/layout/dialog_controller_quick_config.xml index 9dd0160ed..f97ed7cd0 100644 --- a/src/android/app/src/main/res/layout/dialog_controller_quick_config.xml +++ b/src/android/app/src/main/res/layout/dialog_controller_quick_config.xml @@ -3,7 +3,9 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/linearLayout" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:focusable="true" + android:focusableInTouchMode="true"> Up/Down Axis Left/Right Axis Quick Configure + Quickly configure all input buttons (not including hotkeys) + Please Wait Up Down Left