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 509426537..be4cb742e 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 @@ -79,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. @@ -339,5 +369,22 @@ class InputBindingSetting( event.keyCode } } + + fun getInputObject(key: String, preferences: SharedPreferences): AbstractStringSetting { + return object : AbstractStringSetting { + override var string: String + get() = preferences.getString(key, "")!! + set(value) { + preferences.edit() + .putString(key, value) + .apply() + } + override val key = key + override val section = Settings.SECTION_CONTROLS + override val isRuntimeEditable = true + override val valueAsString = preferences.getString(key, "")!! + override val defaultValue = "" + } + } } } 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 new file mode 100644 index 000000000..cad28ea3a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerQuickConfigDialog.kt @@ -0,0 +1,226 @@ +package org.citra.citra_emu.features.settings.ui + +import android.app.AlertDialog +import android.content.Context +import android.content.SharedPreferences +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +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 org.citra.citra_emu.features.settings.utils.InputBindingBase + +class ControllerQuickConfigDialog( + private var context: Context, + buttons: ArrayList>, + titles: ArrayList>, + private var preferences: SharedPreferences, + adapter: SettingsAdapter +) { + private var isWaiting = false; + private var index = 0 + val inflater = LayoutInflater.from(context) + 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 -> + allButtons.add(button) + } + } + titles.forEach { group -> + group.forEach { title -> + allTitles.add(title) + } + } + } + + 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)) { _, _ -> } + .setNegativeButton(context.getString(R.string.close)) { dialog, which -> + dialog.dismiss() + } + + dialog = builder.create() + dialog?.show() + + quickConfigBinding.root.requestFocus() + quickConfigBinding.root.setOnFocusChangeListener { v, hasFocus -> + if (!hasFocus) v.requestFocus() + } + + // Prepare the first element + prepareUIforIndex() + + val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE) + nextButton?.setOnClickListener { + if (setting != null) setting!!.removeOldMapping() + index++ + prepareUIforIndex() + } + } + + 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] + ) + + } + // show the previous key, if this isn't the first key + if (index > 0) { + quickConfigBinding.lastMappingIcon.visibility = View.VISIBLE + quickConfigBinding.lastMappingDescription.visibility = View.VISIBLE + } + + // 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) + } + quickConfigBinding.lastMappingDescription.text = lastTitle + quickConfigBinding.lastMappingIcon.setImageDrawable(quickConfigBinding.currentMappingIcon.drawable) + + quickConfigBinding.currentMappingTitle.text = calculateTitle() + quickConfigBinding.currentMappingDescription.text = setting?.value + quickConfigBinding.currentMappingIcon.setImageDrawable(getIcon()) + + // 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 { + val inputTypeId = when { + setting!!.isCirclePad() -> R.string.controller_circlepad + setting!!.isCStick() -> R.string.controller_c + setting!!.isDPad() -> R.string.controller_dpad + setting!!.isTrigger() -> R.string.controller_trigger + else -> R.string.button + } + + val nameId = setting?.nameId?.let { context.getString(it) } + + return String.format( + context.getString(R.string.input_dialog_title), + context.getString(inputTypeId), + nameId + ) + } + + private fun getIcon(): Drawable? { + val id = when { + setting!!.isCirclePad() -> R.drawable.stick_main + setting!!.isCStick() -> R.drawable.stick_c + setting!!.isDPad() -> R.drawable.dpad + else -> { + val resourceTitle = context.resources.getResourceEntryName(setting!!.nameId) + if (resourceTitle.startsWith("direction")) { + R.drawable.dpad + } else { + context.resources.getIdentifier(resourceTitle, "drawable", context.packageName) + } + } + } + return ContextCompat.getDrawable(context, id) + } + + private var setting: InputBindingSetting? = null + + private var settingsList = arrayListOf() +} \ 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 4bd5d3b5f..680a7a8a2 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 @@ -43,6 +43,7 @@ import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.FloatSetting import org.citra.citra_emu.features.settings.model.ScaledFloatSetting import org.citra.citra_emu.features.settings.model.AbstractShortSetting +import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.features.settings.model.view.DateTimeSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.SettingsItem @@ -64,6 +65,7 @@ import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder import org.citra.citra_emu.features.settings.ui.viewholder.SwitchSettingViewHolder import org.citra.citra_emu.fragments.MessageDialogFragment import org.citra.citra_emu.fragments.MotionBottomSheetDialogFragment +import org.citra.citra_emu.utils.PermissionsHandler import org.citra.citra_emu.utils.SystemSaveGame import java.lang.NumberFormatException import java.text.SimpleDateFormat @@ -610,6 +612,30 @@ class SettingsAdapter( .show() } + fun onClickControllerQuickConfig() { + + val buttons = arrayListOf( + Settings.buttonKeys, + Settings.circlePadKeys, + Settings.cStickKeys, + Settings.dPadAxisKeys, + Settings.dPadButtonKeys, + Settings.triggerKeys + ) + + val titles = arrayListOf( + Settings.buttonTitles, + Settings.axisTitles, + Settings.axisTitles, + Settings.axisTitles, + Settings.dPadTitles, + Settings.triggerTitles + ) + + ControllerQuickConfigDialog(context, buttons, titles, PermissionsHandler.preferences, this).show() + + } + fun closeDialog() { if (dialog != null) { if (clickedPosition != -1) { 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 1326401d5..81e0faf48 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 @@ -776,44 +776,54 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) private fun addControlsSettings(sl: ArrayList) { settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_controls)) sl.apply { + add( + RunnableSetting( + R.string.controller_quick_config, + R.string.controller_quick_config_description, + true, + 0, + { settingsAdapter.onClickControllerQuickConfig() } + ) + ) + add(HeaderSetting(R.string.generic_buttons)) Settings.buttonKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.buttonTitles[i])) } add(HeaderSetting(R.string.controller_circlepad)) Settings.circlePadKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_c)) Settings.cStickKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_dpad_axis,R.string.controller_dpad_axis_description)) Settings.dPadAxisKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_dpad_button,R.string.controller_dpad_button_description)) Settings.dPadButtonKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.dPadTitles[i])) } add(HeaderSetting(R.string.controller_triggers)) Settings.triggerKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.triggerTitles[i])) } add(HeaderSetting(R.string.controller_hotkeys)) Settings.hotKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.hotkeyTitles[i])) } add(HeaderSetting(R.string.miscellaneous)) @@ -829,23 +839,6 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) } } - private fun getInputObject(key: String): AbstractStringSetting { - return object : AbstractStringSetting { - override var string: String - get() = preferences.getString(key, "")!! - set(value) { - preferences.edit() - .putString(key, value) - .apply() - } - override val key = key - override val section = Settings.SECTION_CONTROLS - override val isRuntimeEditable = true - override val valueAsString = preferences.getString(key, "")!! - override val defaultValue = "" - } - } - private fun addGraphicsSettings(sl: ArrayList) { settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics)) sl.apply { 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 new file mode 100644 index 000000000..f97ed7cd0 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_controller_quick_config.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 5bbafd999..a5a647713 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -132,6 +132,9 @@ Only map the D-pad to these if you\'re facing issues with the D-Pad (Axis) button mappings. Up/Down Axis Left/Right Axis + Quick Configure + Quickly configure all input buttons (not including hotkeys) + Please Wait Up Down Left @@ -402,6 +405,8 @@ Don\'t show again Visibility Information + Finish + Unassigned Select Game Folder