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 8ecd60684..297afe861 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 @@ -16,6 +16,7 @@ import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R import org.citra.citra_emu.features.hotkeys.Hotkey import org.citra.citra_emu.features.settings.model.AbstractSetting +import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.Settings class InputBindingSetting( @@ -34,6 +35,8 @@ class InputBindingSetting( .apply() } + private var key: String = "" + /** * Returns true if this key is for the 3DS Circle Pad */ @@ -229,6 +232,29 @@ 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. * @@ -329,5 +355,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..be8c586ce --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerQuickConfigDialog.kt @@ -0,0 +1,237 @@ +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.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 + + +class ControllerQuickConfigDialog( + private var context: Context, + buttons: ArrayList>, + titles: ArrayList>, + private var preferences: SharedPreferences +) { + private var index = 0 + val inflater = LayoutInflater.from(context) + val quickConfigBinding = DialogControllerQuickConfigBinding.inflate(inflater) + var dialog: AlertDialog? = null + + var allButtons = arrayListOf() + var allTitles = arrayListOf() + + 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) + builder + .setView(quickConfigBinding.root) + .setTitle(context.getString(R.string.controller_quick_config)) + .setPositiveButton(context.getString(R.string.controller_quick_config_next)) {_,_ -> } + .setNegativeButton(context.getString(R.string.controller_quick_config_close)) { dialog, which -> + dialog.dismiss() + } + + dialog = builder.create() + dialog?.show() + + dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } + quickConfigBinding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } + + // Prepare the first element + prepareUIforIndex(index) + + val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE) + nextButton?.setOnClickListener { + // Skip to next: + prepareUIforIndex(index++) + } + } + + private fun prepareUIforIndex(i: Int) { + if (allButtons.size-1 < i) { + settingsList.forEach { it.applyMapping() } + dialog?.dismiss() + return + } + + 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) + + var lastTitle = setting?.value ?: "" + if (lastTitle.isBlank()) { + lastTitle = context.getString(R.string.controller_quick_config_unassigned) + } + 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.controller_quick_config_finish) + dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE + } + } + + 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 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 71075b40c..dcb2ea210 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 @@ -44,6 +44,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 @@ -65,6 +66,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.IllegalStateException import java.lang.NumberFormatException @@ -514,6 +516,28 @@ 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).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 31401567f..1bbba31a3 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 @@ -646,44 +646,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, + 0, + false, + 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)) @@ -699,23 +709,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/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..9dd0160ed --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_controller_quick_config.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 5495a2a23..51650bcfe 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -140,6 +140,11 @@ ZR This control must be bound to a gamepad analog stick or D-pad axis! This control must be bound to a gamepad button! + Quick Configure + Finish + Unassigned + Next + Close System Files