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 d78f5c3a3..6ec851db1 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 @@ -9,6 +9,7 @@ import android.content.SharedPreferences import android.view.InputDevice import android.view.InputDevice.MotionRange import android.view.KeyEvent +import android.view.MotionEvent import android.widget.Toast import androidx.preference.PreferenceManager import org.citra.citra_emu.CitraApplication @@ -235,8 +236,7 @@ class InputBindingSetting( val code = translateEventToKeyId(keyEvent) writeButtonMapping(keyEvent) - val uiString = "${keyEvent.device.name}: Button $code" - value = uiString + value = "${keyEvent.device.name}: ${getButtonName(code)}" } /** @@ -263,8 +263,7 @@ class InputBindingSetting( // use UP (-) to map vertical, but use RIGHT (+) to map horizontal val inverted = if (isHorizontalOrientation()) axisDir == '-' else axisDir == '+' writeAxisMapping(motionRange.axis, button, inverted) - val uiString = "${device.name}: Axis ${motionRange.axis}" + axisDir - value = uiString + value = "Axis ${motionRange.axis}$axisDir" } override val type = TYPE_INPUT_BINDING @@ -272,6 +271,241 @@ class InputBindingSetting( companion object { private const val INPUT_MAPPING_PREFIX = "InputMapping" + private fun toTitleCase(raw: String): String = + raw.replace("_", " ").lowercase() + .split(" ").joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } + + private const val BUTTON_NAME_L3 = "Button L3" + private const val BUTTON_NAME_R3 = "Button R3" + + private val buttonNameOverrides = mapOf( + KeyEvent.KEYCODE_BUTTON_THUMBL to BUTTON_NAME_L3, + KeyEvent.KEYCODE_BUTTON_THUMBR to BUTTON_NAME_R3, + LINUX_BTN_DPAD_UP to "Dpad Up", + LINUX_BTN_DPAD_DOWN to "Dpad Down", + LINUX_BTN_DPAD_LEFT to "Dpad Left", + LINUX_BTN_DPAD_RIGHT to "Dpad Right" + ) + + fun getButtonName(keyCode: Int): String = + buttonNameOverrides[keyCode] + ?: toTitleCase(KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_")) + + private data class DefaultButtonMapping( + val settingKey: String, + val hostKeyCode: Int, + val guestButtonCode: Int + ) + // Auto-map always sets inverted = false. Users needing inverted axes should remap manually. + private data class DefaultAxisMapping( + val settingKey: String, + val hostAxis: Int, + val guestButton: Int, + val orientation: Int, + val inverted: Boolean + ) + + private val xboxFaceButtonMappings = listOf( + DefaultButtonMapping(Settings.KEY_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, NativeLibrary.ButtonType.BUTTON_A), + DefaultButtonMapping(Settings.KEY_BUTTON_B, KeyEvent.KEYCODE_BUTTON_A, NativeLibrary.ButtonType.BUTTON_B), + DefaultButtonMapping(Settings.KEY_BUTTON_X, KeyEvent.KEYCODE_BUTTON_Y, NativeLibrary.ButtonType.BUTTON_X), + DefaultButtonMapping(Settings.KEY_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_X, NativeLibrary.ButtonType.BUTTON_Y) + ) + + private val nintendoFaceButtonMappings = listOf( + DefaultButtonMapping(Settings.KEY_BUTTON_A, KeyEvent.KEYCODE_BUTTON_A, NativeLibrary.ButtonType.BUTTON_A), + DefaultButtonMapping(Settings.KEY_BUTTON_B, KeyEvent.KEYCODE_BUTTON_B, NativeLibrary.ButtonType.BUTTON_B), + DefaultButtonMapping(Settings.KEY_BUTTON_X, KeyEvent.KEYCODE_BUTTON_X, NativeLibrary.ButtonType.BUTTON_X), + DefaultButtonMapping(Settings.KEY_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y, NativeLibrary.ButtonType.BUTTON_Y) + ) + + private val commonButtonMappings = listOf( + DefaultButtonMapping(Settings.KEY_BUTTON_L, KeyEvent.KEYCODE_BUTTON_L1, NativeLibrary.ButtonType.TRIGGER_L), + DefaultButtonMapping(Settings.KEY_BUTTON_R, KeyEvent.KEYCODE_BUTTON_R1, NativeLibrary.ButtonType.TRIGGER_R), + DefaultButtonMapping(Settings.KEY_BUTTON_ZL, KeyEvent.KEYCODE_BUTTON_L2, NativeLibrary.ButtonType.BUTTON_ZL), + DefaultButtonMapping(Settings.KEY_BUTTON_ZR, KeyEvent.KEYCODE_BUTTON_R2, NativeLibrary.ButtonType.BUTTON_ZR), + DefaultButtonMapping(Settings.KEY_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_SELECT, NativeLibrary.ButtonType.BUTTON_SELECT), + DefaultButtonMapping(Settings.KEY_BUTTON_START, KeyEvent.KEYCODE_BUTTON_START, NativeLibrary.ButtonType.BUTTON_START) + ) + + private val dpadButtonMappings = listOf( + DefaultButtonMapping(Settings.KEY_BUTTON_UP, KeyEvent.KEYCODE_DPAD_UP, NativeLibrary.ButtonType.DPAD_UP), + DefaultButtonMapping(Settings.KEY_BUTTON_DOWN, KeyEvent.KEYCODE_DPAD_DOWN, NativeLibrary.ButtonType.DPAD_DOWN), + DefaultButtonMapping(Settings.KEY_BUTTON_LEFT, KeyEvent.KEYCODE_DPAD_LEFT, NativeLibrary.ButtonType.DPAD_LEFT), + DefaultButtonMapping(Settings.KEY_BUTTON_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT, NativeLibrary.ButtonType.DPAD_RIGHT) + ) + + private val stickAxisMappings = listOf( + DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL, MotionEvent.AXIS_X, NativeLibrary.ButtonType.STICK_LEFT, 0, false), + DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_VERTICAL, MotionEvent.AXIS_Y, NativeLibrary.ButtonType.STICK_LEFT, 1, false), + DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_HORIZONTAL, MotionEvent.AXIS_Z, NativeLibrary.ButtonType.STICK_C, 0, false), + DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_VERTICAL, MotionEvent.AXIS_RZ, NativeLibrary.ButtonType.STICK_C, 1, false) + ) + + private val dpadAxisMappings = listOf( + DefaultAxisMapping(Settings.KEY_DPAD_AXIS_HORIZONTAL, MotionEvent.AXIS_HAT_X, NativeLibrary.ButtonType.DPAD, 0, false), + DefaultAxisMapping(Settings.KEY_DPAD_AXIS_VERTICAL, MotionEvent.AXIS_HAT_Y, NativeLibrary.ButtonType.DPAD, 1, false) + ) + + // Nintendo Switch Joy-Con specific mappings. + // Joy-Cons connected via Bluetooth on Android have several quirks: + // - They register as two separate InputDevices (left and right) + // - Android's evdev translation swaps A<->B (BTN_EAST->BUTTON_B, BTN_SOUTH->BUTTON_A) + // but does NOT swap X<->Y (BTN_NORTH->BUTTON_X, BTN_WEST->BUTTON_Y) + // - D-pad buttons arrive as KEYCODE_UNKNOWN (0) with Linux BTN_DPAD_* scan codes + // - Right stick uses AXIS_RX/AXIS_RY instead of AXIS_Z/AXIS_RZ + private const val NINTENDO_VENDOR_ID = 0x057e + + // Linux BTN_DPAD_* values (0x220-0x223). Joy-Con D-pad buttons arrive as + // KEYCODE_UNKNOWN with these scan codes because Android's input layer doesn't + // translate them to KEYCODE_DPAD_*. translateEventToKeyId() falls back to + // the scan code in that case. + private const val LINUX_BTN_DPAD_UP = 0x220 // 544 + private const val LINUX_BTN_DPAD_DOWN = 0x221 // 545 + private const val LINUX_BTN_DPAD_LEFT = 0x222 // 546 + private const val LINUX_BTN_DPAD_RIGHT = 0x223 // 547 + + // Joy-Con face buttons: A/B are swapped by Android's evdev layer, but X/Y are not. + // This is different from both the standard Xbox table (full swap) and the + // Nintendo table (no swap). + private val joyconFaceButtonMappings = listOf( + DefaultButtonMapping(Settings.KEY_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, NativeLibrary.ButtonType.BUTTON_A), + DefaultButtonMapping(Settings.KEY_BUTTON_B, KeyEvent.KEYCODE_BUTTON_A, NativeLibrary.ButtonType.BUTTON_B), + DefaultButtonMapping(Settings.KEY_BUTTON_X, KeyEvent.KEYCODE_BUTTON_X, NativeLibrary.ButtonType.BUTTON_X), + DefaultButtonMapping(Settings.KEY_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y, NativeLibrary.ButtonType.BUTTON_Y) + ) + + // Joy-Con D-pad: uses Linux scan codes because Android reports BTN_DPAD_* as KEYCODE_UNKNOWN + private val joyconDpadButtonMappings = listOf( + DefaultButtonMapping(Settings.KEY_BUTTON_UP, LINUX_BTN_DPAD_UP, NativeLibrary.ButtonType.DPAD_UP), + DefaultButtonMapping(Settings.KEY_BUTTON_DOWN, LINUX_BTN_DPAD_DOWN, NativeLibrary.ButtonType.DPAD_DOWN), + DefaultButtonMapping(Settings.KEY_BUTTON_LEFT, LINUX_BTN_DPAD_LEFT, NativeLibrary.ButtonType.DPAD_LEFT), + DefaultButtonMapping(Settings.KEY_BUTTON_RIGHT, LINUX_BTN_DPAD_RIGHT, NativeLibrary.ButtonType.DPAD_RIGHT) + ) + + // Joy-Con sticks: left stick is AXIS_X/Y (standard), right stick is AXIS_RX/RY + // (not Z/RZ like most controllers). The horizontal axis is inverted relative to + // the standard orientation - verified empirically on paired Joy-Cons via Bluetooth. + private val joyconStickAxisMappings = listOf( + DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL, MotionEvent.AXIS_X, NativeLibrary.ButtonType.STICK_LEFT, 0, false), + DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_VERTICAL, MotionEvent.AXIS_Y, NativeLibrary.ButtonType.STICK_LEFT, 1, false), + DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_HORIZONTAL, MotionEvent.AXIS_RX, NativeLibrary.ButtonType.STICK_C, 0, true), + DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_VERTICAL, MotionEvent.AXIS_RY, NativeLibrary.ButtonType.STICK_C, 1, false) + ) + + /** + * Detects whether a device is a Nintendo Switch Joy-Con (as opposed to a + * Pro Controller or other Nintendo device) by checking vendor ID + device + * capabilities. Joy-Cons lack AXIS_HAT_X/Y and use AXIS_RX/RY for the + * right stick, while the Pro Controller has standard HAT axes and Z/RZ. + */ + fun isJoyCon(device: InputDevice?): Boolean { + if (device == null) return false + if (device.vendorId != NINTENDO_VENDOR_ID) return false + + // Pro Controllers have HAT_X/HAT_Y (D-pad) and Z/RZ (right stick). + // Joy-Cons lack both: no HAT axes, right stick on RX/RY instead of Z/RZ. + var hasHatAxes = false + var hasStandardRightStick = false + for (range in device.motionRanges) { + when (range.axis) { + MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> hasHatAxes = true + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> hasStandardRightStick = true + } + } + return !hasHatAxes && !hasStandardRightStick + } + + private val allBindingKeys: Set by lazy { + (Settings.buttonKeys + Settings.triggerKeys + + Settings.circlePadKeys + Settings.cStickKeys + Settings.dPadAxisKeys + + Settings.dPadButtonKeys).toSet() + } + + fun clearAllBindings() { + val prefs = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + val editor = prefs.edit() + val allKeys = prefs.all.keys.toList() + for (key in allKeys) { + if (key.startsWith(INPUT_MAPPING_PREFIX) || key in allBindingKeys) { + editor.remove(key) + } + } + editor.apply() + } + + private fun applyBindings( + buttonMappings: List, + axisMappings: List + ) { + val prefs = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + val editor = prefs.edit() + buttonMappings.forEach { applyDefaultButtonMapping(editor, it) } + axisMappings.forEach { applyDefaultAxisMapping(editor, it) } + editor.apply() + } + + /** + * Applies Joy-Con specific bindings: scan code D-pad, partial face button + * swap, and AXIS_RX/RY right stick. + */ + fun applyJoyConBindings() { + applyBindings( + joyconFaceButtonMappings + commonButtonMappings + joyconDpadButtonMappings, + joyconStickAxisMappings + ) + } + + /** + * Applies auto-mapped bindings based on detected controller layout and d-pad type. + * + * @param isNintendoLayout true if the controller uses Nintendo face button layout + * (A=east, B=south), false for Xbox layout (A=south, B=east) + * @param useAxisDpad true if the d-pad should be mapped as axis (HAT_X/HAT_Y), + * false if it should be mapped as individual button keycodes (DPAD_UP/DOWN/LEFT/RIGHT) + */ + fun applyAutoMapBindings(isNintendoLayout: Boolean, useAxisDpad: Boolean) { + val faceButtons = if (isNintendoLayout) nintendoFaceButtonMappings else xboxFaceButtonMappings + val buttonMappings = if (useAxisDpad) { + faceButtons + commonButtonMappings + } else { + faceButtons + commonButtonMappings + dpadButtonMappings + } + val axisMappings = if (useAxisDpad) { + stickAxisMappings + dpadAxisMappings + } else { + stickAxisMappings + } + applyBindings(buttonMappings, axisMappings) + } + + private fun applyDefaultButtonMapping( + editor: SharedPreferences.Editor, + mapping: DefaultButtonMapping + ) { + val prefKey = getInputButtonKey(mapping.hostKeyCode) + editor.putInt(prefKey, mapping.guestButtonCode) + editor.putString(mapping.settingKey, getButtonName(mapping.hostKeyCode)) + editor.putString( + "${INPUT_MAPPING_PREFIX}_ReverseMapping_${mapping.settingKey}", + prefKey + ) + } + + private fun applyDefaultAxisMapping( + editor: SharedPreferences.Editor, + mapping: DefaultAxisMapping + ) { + val axisKey = getInputAxisKey(mapping.hostAxis) + editor.putInt(getInputAxisOrientationKey(mapping.hostAxis), mapping.orientation) + editor.putInt(getInputAxisButtonKey(mapping.hostAxis), mapping.guestButton) + editor.putBoolean(getInputAxisInvertedKey(mapping.hostAxis), mapping.inverted) + val dir = if (mapping.orientation == 0) '+' else '-' + editor.putString(mapping.settingKey, "Axis ${mapping.hostAxis}$dir") + val reverseKey = "${INPUT_MAPPING_PREFIX}_ReverseMapping_${mapping.settingKey}_${mapping.orientation}" + editor.putString(reverseKey, axisKey) + } + /** * Returns the settings key for the specified Citra button code. */ @@ -315,18 +549,10 @@ class InputBindingSetting( return buttonCodes.mapNotNull { it.toIntOrNull() }.toMutableSet() } - /** - * Helper function to get the settings key for an gamepad button. - * - */ - @Deprecated("Use the new getInputButtonKey(keyEvent) method to handle unknown keys") - fun getInputButtonKey(keyCode: Int): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${keyCode}" + private fun getInputButtonKey(keyId: Int): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${keyId}" - /** - * Helper function to get the settings key for an gamepad button. - * - */ - fun getInputButtonKey(event: KeyEvent): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${translateEventToKeyId(event)}" + /** Falls back to the scan code when keyCode is KEYCODE_UNKNOWN. */ + fun getInputButtonKey(event: KeyEvent): String = getInputButtonKey(translateEventToKeyId(event)) /** * Helper function to get the settings key for an gamepad axis. diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/RunnableSetting.kt index 99039556b..54e8fd09b 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/RunnableSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/RunnableSetting.kt @@ -1,10 +1,11 @@ -// Copyright 2023 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. package org.citra.citra_emu.features.settings.model.view import androidx.annotation.DrawableRes +import org.citra.citra_emu.activities.EmulationActivity class RunnableSetting( titleId: Int, @@ -12,7 +13,11 @@ class RunnableSetting( val isRuntimeRunnable: Boolean, @DrawableRes val iconId: Int = 0, val runnable: () -> Unit, - val value: (() -> String)? = null + val value: (() -> String)? = null, + val onLongClick: (() -> Boolean)? = null ) : SettingsItem(null, titleId, descriptionId) { override val type = TYPE_RUNNABLE + + override val isEditable: Boolean + get() = if (EmulationActivity.isRunning()) isRuntimeRunnable else true } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.kt index 68aa2226c..066912dd9 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.kt @@ -22,7 +22,7 @@ abstract class SettingsItem( ) { abstract val type: Int - val isEditable: Boolean + open val isEditable: Boolean get() { if (!EmulationActivity.isRunning()) return true return setting?.isRuntimeEditable ?: false 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 054ff8d63..43a1dcbbd 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 @@ -65,6 +65,7 @@ import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder import org.citra.citra_emu.features.settings.ui.viewholder.StringInputViewHolder 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.AutoMapDialogFragment import org.citra.citra_emu.fragments.MessageDialogFragment import org.citra.citra_emu.fragments.MotionBottomSheetDialogFragment import org.citra.citra_emu.utils.SystemSaveGame @@ -642,26 +643,42 @@ class SettingsAdapter( ).show((fragmentView as SettingsFragment).childFragmentManager, MessageDialogFragment.TAG) } + fun onClickAutoMap() { + val activity = fragmentView.activityView as FragmentActivity + AutoMapDialogFragment.newInstance { + fragmentView.loadSettingsList() + fragmentView.onSettingChanged() + }.show(activity.supportFragmentManager, AutoMapDialogFragment.TAG) + } + + fun onLongClickAutoMap(): Boolean { + showConfirmationDialog(R.string.controller_clear_all, R.string.controller_clear_all_confirm) { + InputBindingSetting.clearAllBindings() + fragmentView.loadSettingsList() + fragmentView.onSettingChanged() + } + return true + } + fun onClickRegenerateConsoleId() { - MaterialAlertDialogBuilder(context) - .setTitle(R.string.regenerate_console_id) - .setMessage(R.string.regenerate_console_id_description) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - SystemSaveGame.regenerateConsoleId() - notifyDataSetChanged() - } - .setNegativeButton(android.R.string.cancel, null) - .show() + showConfirmationDialog(R.string.regenerate_console_id, R.string.regenerate_console_id_description) { + SystemSaveGame.regenerateConsoleId() + notifyDataSetChanged() + } } fun onClickRegenerateMAC() { + showConfirmationDialog(R.string.regenerate_mac_address, R.string.regenerate_mac_address_description) { + SystemSaveGame.regenerateMac() + notifyDataSetChanged() + } + } + + private fun showConfirmationDialog(titleId: Int, messageId: Int, onConfirm: () -> Unit) { MaterialAlertDialogBuilder(context) - .setTitle(R.string.regenerate_mac_address) - .setMessage(R.string.regenerate_mac_address_description) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - SystemSaveGame.regenerateMac() - notifyDataSetChanged() - } + .setTitle(titleId) + .setMessage(messageId) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> onConfirm() } .setNegativeButton(android.R.string.cancel, null) .show() } 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 be70309ac..1b7812342 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 @@ -779,6 +779,16 @@ 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_auto_map, + R.string.controller_auto_map_description, + true, + R.drawable.ic_controller, + { settingsAdapter.onClickAutoMap() }, + onLongClick = { settingsAdapter.onLongClickAutoMap() } + ) + ) add(HeaderSetting(R.string.generic_buttons)) Settings.buttonKeys.forEachIndexed { i: Int, key: String -> val button = getInputObject(key) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/RunnableViewHolder.kt index e3119e60d..d75368598 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/RunnableViewHolder.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/RunnableViewHolder.kt @@ -67,7 +67,10 @@ class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsA } override fun onLongClick(clicked: View): Boolean { - // no-op - return true + if (!setting.isEditable) { + adapter.onClickDisabledSetting(true) + return true + } + return setting.onLongClick?.invoke() ?: true } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/AutoMapDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/AutoMapDialogFragment.kt new file mode 100644 index 000000000..569a0caca --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/AutoMapDialogFragment.kt @@ -0,0 +1,152 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +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 +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogAutoMapBinding +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting +import org.citra.citra_emu.utils.Log + +/** + * Captures a single button press to detect controller layout (Xbox vs Nintendo) + * and d-pad type (axis vs button), then applies the appropriate bindings. + */ +class AutoMapDialogFragment : BottomSheetDialogFragment() { + private var _binding: DialogAutoMapBinding? = null + private val binding get() = _binding!! + + private var onComplete: (() -> Unit)? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogAutoMapBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + BottomSheetBehavior.from(view.parent as View).state = + BottomSheetBehavior.STATE_EXPANDED + + isCancelable = false + view.requestFocus() + view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + + binding.textTitle.setText(R.string.controller_auto_map) + binding.textMessage.setText(R.string.auto_map_prompt) + + binding.imageFaceButtons.setImageResource(R.drawable.automap_face_buttons) + + dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } + + binding.buttonCancel.setOnClickListener { + dismiss() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun onKeyEvent(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_UP) return false + + val keyCode = event.keyCode + val device = event.device + + // Check if this is a Nintendo Switch Joy-Con (not Pro Controller). + // Joy-Cons have unique quirks: split devices, non-standard D-pad scan codes, + // partial A/B swap but no X/Y swap from Android's evdev layer. + val isJoyCon = InputBindingSetting.isJoyCon(device) + + if (isJoyCon) { + Log.info("[AutoMap] Detected Joy-Con - using Joy-Con mappings") + InputBindingSetting.clearAllBindings() + InputBindingSetting.applyJoyConBindings() + onComplete?.invoke() + dismiss() + return true + } + + // For non-Joy-Con controllers, determine layout from which keycode arrives + // for the east/right position. + // The user is pressing the button in the "A" (east/right) position on the 3DS diamond. + // Xbox layout: east position sends KEYCODE_BUTTON_B (97) + // Nintendo layout: east position sends KEYCODE_BUTTON_A (96) + val isNintendoLayout = when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> true + KeyEvent.KEYCODE_BUTTON_B -> false + else -> { + // Unrecognized button - ignore and wait for a valid press + Log.warning("[AutoMap] Ignoring unrecognized keycode $keyCode, waiting for A or B") + return true + } + } + + val layoutName = if (isNintendoLayout) "Nintendo" else "Xbox" + Log.info("[AutoMap] Detected $layoutName layout (keyCode=$keyCode)") + + val useAxisDpad = detectDpadType(device) + + val dpadName = if (useAxisDpad) "axis" else "button" + Log.info("[AutoMap] Detected $dpadName d-pad (device=${device?.name})") + + InputBindingSetting.clearAllBindings() + InputBindingSetting.applyAutoMapBindings(isNintendoLayout, useAxisDpad) + + onComplete?.invoke() + dismiss() + return true + } + + companion object { + const val TAG = "AutoMapDialogFragment" + + fun newInstance( + onComplete: () -> Unit + ): AutoMapDialogFragment { + val dialog = AutoMapDialogFragment() + dialog.onComplete = onComplete + return dialog + } + + /** + * Returns true for axis d-pad (HAT_X/HAT_Y), false for button d-pad (DPAD_UP/DOWN/LEFT/RIGHT). + * Prefers axis when both are present. Defaults to axis if detection fails. + */ + private fun detectDpadType(device: InputDevice?): Boolean { + if (device == null) return true + + val hasAxisDpad = device.motionRanges.any { + it.axis == MotionEvent.AXIS_HAT_X || it.axis == MotionEvent.AXIS_HAT_Y + } + + if (hasAxisDpad) return true + + val dpadKeyCodes = intArrayOf( + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT + ) + val hasButtonDpad = device.hasKeys(*dpadKeyCodes).any { it } + + return !hasButtonDpad + } + } +} diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/automap_face_buttons.png b/src/android/app/src/main/res/drawable-xxxhdpi/automap_face_buttons.png new file mode 100644 index 000000000..60f7c2bad Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/automap_face_buttons.png differ diff --git a/src/android/app/src/main/res/layout/dialog_auto_map.xml b/src/android/app/src/main/res/layout/dialog_auto_map.xml new file mode 100644 index 000000000..95cb8d138 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_auto_map.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + +