This commit is contained in:
David Griswold 2026-03-28 16:34:09 -06:00 committed by GitHub
commit 035f2f84b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 2261 additions and 2251 deletions

View File

@ -234,6 +234,37 @@ if (ANDROID)
"android_hide_images"
"screen_orientation"
"performance_overlay_position"
"button_a"
"button_b"
"button_x"
"button_y"
"button_home"
"circlepad_up"
"circlepad_down"
"circlepad_left"
"circlepad_right"
"button_r"
"button_l"
"button_zr"
"button_zl"
"button_start"
"button_select"
"dpad_up"
"dpad_down"
"dpad_left"
"dpad_right"
"cstick_up"
"cstick_down"
"cstick_left"
"cstick_right"
"hotkey_cycle_layout"
"hotkey_close"
"hotkey_swap"
"hotkey_pause_resume"
"hotkey_quicksave"
"hotkey_turbo_limit"
"hotkey_quickload"
"hotkey_enable"
)
string(REPLACE "_" "_1" KEY_JNI_ESCAPED ${KEY})
set(SETTING_KEY_LIST "${SETTING_KEY_LIST}\n\"${KEY}\",")

View File

@ -959,6 +959,7 @@ object NativeLibrary {
const val DPAD = 780
const val BUTTON_DEBUG = 781
const val BUTTON_GPIO14 = 782
// TODO: replace these with Hotkey buttons - they aren't native!
const val BUTTON_SWAP = 800
const val BUTTON_TURBO = 801
}

View File

@ -7,7 +7,6 @@ package org.citra.citra_emu.activities
import android.Manifest.permission
import android.annotation.SuppressLint
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
@ -26,25 +25,23 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.navigation.fragment.NavHostFragment
import androidx.preference.PreferenceManager
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.NativeLibrary.ButtonState
import org.citra.citra_emu.R
import org.citra.citra_emu.camera.StillImageCameraHelper.OnFilePickerResult
import org.citra.citra_emu.contracts.OpenFileResultContract
import org.citra.citra_emu.databinding.ActivityEmulationBinding
import org.citra.citra_emu.display.ScreenAdjustmentUtil
import org.citra.citra_emu.display.SecondaryDisplay
import org.citra.citra_emu.features.hotkeys.HotkeyUtility
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.input.HotkeyUtility
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.fragments.EmulationFragment
import org.citra.citra_emu.fragments.MessageDialogFragment
import org.citra.citra_emu.model.Game
import org.citra.citra_emu.utils.BuildUtil
import org.citra.citra_emu.utils.ControllerMappingHelper
import org.citra.citra_emu.utils.FileBrowserHelper
import org.citra.citra_emu.utils.EmulationLifecycleUtil
import org.citra.citra_emu.utils.EmulationMenuSettings
@ -52,14 +49,12 @@ import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.utils.RefreshRateUtil
import org.citra.citra_emu.utils.ThemeUtil
import org.citra.citra_emu.viewmodel.EmulationViewModel
import org.citra.citra_emu.features.settings.utils.SettingsFile
import kotlin.math.abs
class EmulationActivity : AppCompatActivity() {
private val preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
var isActivityRecreated = false
private val emulationViewModel: EmulationViewModel by viewModels()
val settingsViewModel: SettingsViewModel by viewModels()
val emulationViewModel: EmulationViewModel by viewModels()
private lateinit var binding: ActivityEmulationBinding
private lateinit var screenAdjustmentUtil: ScreenAdjustmentUtil
private lateinit var hotkeyUtility: HotkeyUtility
@ -88,14 +83,32 @@ class EmulationActivity : AppCompatActivity() {
RefreshRateUtil.enforceRefreshRate(this, sixtyHz = true)
ThemeUtil.setTheme(this)
settingsViewModel.settings.loadSettings()
val game = try {
intent.extras?.let { extras ->
BundleCompat.getParcelable(extras, "game", Game::class.java)
} ?: run {
Log.error("[EmulationActivity] Missing game data in intent extras")
return
}
} catch (e: Exception) {
Log.error("[EmulationActivity] Failed to retrieve game data: ${e.message}")
return
}
// load global settings if for some reason they aren't (should be loaded in MainActivity)
if (Settings.settings.getAllGlobal().isEmpty()) {
SettingsFile.loadSettings(Settings.settings)
}
// load per-game settings
SettingsFile.loadSettings(Settings.settings, String.format("%016X", game.titleId))
super.onCreate(savedInstanceState)
secondaryDisplay = SecondaryDisplay(this)
secondaryDisplay = SecondaryDisplay(this, Settings.settings)
secondaryDisplay.updateDisplay()
binding = ActivityEmulationBinding.inflate(layoutInflater)
screenAdjustmentUtil = ScreenAdjustmentUtil(this, windowManager, settingsViewModel.settings)
hotkeyUtility = HotkeyUtility(screenAdjustmentUtil, this)
screenAdjustmentUtil = ScreenAdjustmentUtil(this, windowManager, Settings.settings)
hotkeyUtility = HotkeyUtility(screenAdjustmentUtil, this, Settings.settings)
setContentView(binding.root)
val navHostFragment =
@ -121,18 +134,6 @@ class EmulationActivity : AppCompatActivity() {
applyOrientationSettings() // Check for orientation settings at startup
val game = try {
intent.extras?.let { extras ->
BundleCompat.getParcelable(extras, "game", Game::class.java)
} ?: run {
Log.error("[EmulationActivity] Missing game data in intent extras")
return
}
} catch (e: Exception) {
Log.error("[EmulationActivity] Failed to retrieve game data: ${e.message}")
return
}
NativeLibrary.playTimeManagerStart(game.titleId)
}
@ -142,7 +143,7 @@ class EmulationActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
enableFullscreenImmersive()
applyOrientationSettings() // Check for orientation settings changes on runtime
applyOrientationSettings()
}
override fun onStop() {
@ -179,6 +180,8 @@ class EmulationActivity : AppCompatActivity() {
secondaryDisplay.releasePresentation()
secondaryDisplay.releaseVD()
Settings.settings.removePerGameSettings()
super.onDestroy()
}
@ -229,11 +232,11 @@ class EmulationActivity : AppCompatActivity() {
).show()
}
private fun enableFullscreenImmersive() {
fun enableFullscreenImmersive() {
val attributes = window.attributes
attributes.layoutInDisplayCutoutMode =
if (BooleanSetting.EXPAND_TO_CUTOUT_AREA.boolean) {
if (Settings.settings.get(BooleanSetting.EXPAND_TO_CUTOUT_AREA)) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
@ -250,8 +253,8 @@ class EmulationActivity : AppCompatActivity() {
}
}
private fun applyOrientationSettings() {
val orientationOption = IntSetting.ORIENTATION_OPTION.int
fun applyOrientationSettings() {
val orientationOption = Settings.settings.get(IntSetting.ORIENTATION_OPTION)
screenAdjustmentUtil.changeActivityOrientation(orientationOption)
}
@ -281,13 +284,13 @@ class EmulationActivity : AppCompatActivity() {
return true
}
}
return hotkeyUtility.handleKeyPress(event)
return hotkeyUtility.handleKeyPress(if (event.keyCode == 0) event.scanCode else event.keyCode, event.device.descriptor)
}
KeyEvent.ACTION_UP -> {
return hotkeyUtility.handleKeyRelease(event)
return hotkeyUtility.handleKeyRelease(if (event.keyCode == 0) event.scanCode else event.keyCode, event.device.descriptor)
}
else -> {
return false;
return false
}
}
}
@ -307,7 +310,8 @@ class EmulationActivity : AppCompatActivity() {
// TODO: Move this check into native code - prevents crash if input pressed before starting emulation
if (!NativeLibrary.isRunning() ||
(event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) ||
emulationFragment.isDrawerOpen()) {
emulationFragment.isDrawerOpen()
) {
return super.dispatchGenericMotionEvent(event)
}
@ -315,206 +319,76 @@ class EmulationActivity : AppCompatActivity() {
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return true
}
val input = event.device
val motions = input.motionRanges
val axisValuesCirclePad = floatArrayOf(0.0f, 0.0f)
val axisValuesCStick = floatArrayOf(0.0f, 0.0f)
val axisValuesDPad = floatArrayOf(0.0f, 0.0f)
var isTriggerPressedLMapped = false
var isTriggerPressedRMapped = false
var isTriggerPressedZLMapped = false
var isTriggerPressedZRMapped = false
var isTriggerPressedL = false
var isTriggerPressedR = false
var isTriggerPressedZL = false
var isTriggerPressedZR = false
for (range in motions) {
val device = event.device
val manager = emulationViewModel.settings.inputMappingManager
val stickAccumulator = HashMap<Int, Pair<Float, Float>>()
for (range in device.motionRanges) {
val axis = range.axis
val origValue = event.getAxisValue(axis)
var value = ControllerMappingHelper.scaleAxis(input, axis, origValue)
val nextMapping =
preferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1)
val guestOrientation =
preferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1)
val inverted = preferences.getBoolean(InputBindingSetting.getInputAxisInvertedKey(axis),false);
if (nextMapping == -1 || guestOrientation == -1) {
// Axis is unmapped
var value = GamepadHelper.scaleAxis(device, axis, origValue)
if (value > -0.1f && value < 0.1f) value = 0f
val axisPair = Pair(axis, if (value >= 0f) 1 else -1)
// special case where the axis value is 0 - we need to send releases to both directions
if (value == 0f) {
listOf(Pair(axis, 1), Pair(axis, -1)).forEach { zeroPair ->
manager.getOutAxesForAxis(zeroPair).forEach { (outAxis, outDir) ->
val component =
GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
val current =
stickAccumulator.getOrDefault(component.joystickType, Pair(0f, 0f))
stickAccumulator[component.joystickType] = if (component.isVertical) {
Pair(current.first, 0f)
} else {
Pair(0f, current.second)
}
}
manager.getOutButtonsForAxis(zeroPair).forEach { outButton ->
NativeLibrary.onGamePadEvent(
device.descriptor,
outButton,
ButtonState.RELEASED
)
}
}
continue
}
if (value > 0f && value < 0.1f || value < 0f && value > -0.1f) {
// Skip joystick wobble
value = 0f
// Axis to Axis mappings
manager.getOutAxesForAxis(axisPair).forEach { (outAxis, outDir) ->
val component = GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
val current =
stickAccumulator.getOrDefault(component.joystickType, Pair(0f, 0f))
val contribution = abs(value) * outDir
stickAccumulator[component.joystickType] = if (component.isVertical) {
Pair(current.first, contribution)
} else {
Pair(contribution, current.second)
}
}
if (inverted) value = -value;
when (nextMapping) {
NativeLibrary.ButtonType.STICK_LEFT -> {
axisValuesCirclePad[guestOrientation] = value
}
NativeLibrary.ButtonType.STICK_C -> {
axisValuesCStick[guestOrientation] = value
}
NativeLibrary.ButtonType.DPAD -> {
axisValuesDPad[guestOrientation] = value
}
NativeLibrary.ButtonType.TRIGGER_L -> {
isTriggerPressedLMapped = true
isTriggerPressedL = value != 0f
}
NativeLibrary.ButtonType.TRIGGER_R -> {
isTriggerPressedRMapped = true
isTriggerPressedR = value != 0f
}
NativeLibrary.ButtonType.BUTTON_ZL -> {
isTriggerPressedZLMapped = true
isTriggerPressedZL = value != 0f
}
NativeLibrary.ButtonType.BUTTON_ZR -> {
isTriggerPressedZRMapped = true
isTriggerPressedZR = value != 0f
// Axis to Button mappings
manager.getOutButtonsForAxis(axisPair).forEach { button ->
val mapping = manager.getMappingForButton(button)
if (abs(value) > (mapping?.threshold ?: 0.5f)) {
hotkeyUtility.handleKeyPress(button, device.descriptor)
NativeLibrary.onGamePadEvent(device.descriptor, button, ButtonState.PRESSED)
} else {
NativeLibrary.onGamePadEvent(
device.descriptor,
button,
ButtonState.RELEASED
)
}
}
}
// Circle-Pad and C-Stick status
NativeLibrary.onGamePadMoveEvent(
input.descriptor,
NativeLibrary.ButtonType.STICK_LEFT,
axisValuesCirclePad[0],
axisValuesCirclePad[1]
)
NativeLibrary.onGamePadMoveEvent(
input.descriptor,
NativeLibrary.ButtonType.STICK_C,
axisValuesCStick[0],
axisValuesCStick[1]
)
// Triggers L/R and ZL/ZR
if (isTriggerPressedLMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.TRIGGER_L,
if (isTriggerPressedL) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
}
if (isTriggerPressedRMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.TRIGGER_R,
if (isTriggerPressedR) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
}
if (isTriggerPressedZLMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.BUTTON_ZL,
if (isTriggerPressedZL) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
}
if (isTriggerPressedZRMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.BUTTON_ZR,
if (isTriggerPressedZR) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
stickAccumulator.forEach { (outAxis, value) ->
NativeLibrary.onGamePadMoveEvent(device.descriptor, outAxis, value.first, value.second)
}
// Work-around to allow D-pad axis to be bound to emulated buttons
if (axisValuesDPad[0] == 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_LEFT,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_RIGHT,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[0] < 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_LEFT,
NativeLibrary.ButtonState.PRESSED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_RIGHT,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[0] > 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_LEFT,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_RIGHT,
NativeLibrary.ButtonState.PRESSED
)
}
if (axisValuesDPad[1] == 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_UP,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_DOWN,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[1] < 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_UP,
NativeLibrary.ButtonState.PRESSED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_DOWN,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[1] > 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_UP,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_DOWN,
NativeLibrary.ButtonState.PRESSED
)
}
return true
}

View File

@ -59,6 +59,8 @@ import org.citra.citra_emu.utils.FileUtil
import org.citra.citra_emu.utils.GameIconUtils
import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.viewmodel.GamesViewModel
import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
class GameAdapter(
private val activity: AppCompatActivity,
@ -485,6 +487,15 @@ class GameAdapter(
bottomSheetDialog.dismiss()
}
bottomSheetView.findViewById<MaterialButton>(R.id.application_settings).setOnClickListener {
SettingsActivity.launch(
context,
SettingsFile.FILE_NAME_CONFIG,
String.format("%016X", holder.game.titleId)
)
bottomSheetDialog.dismiss()
}
val compressDecompressButton = bottomSheetView.findViewById<MaterialButton>(R.id.compress_decompress)
if (game.isInstalled) {
compressDecompressButton.setOnClickListener {

View File

@ -29,13 +29,13 @@ class ScreenAdjustmentUtil(
isEnabled,
windowManager.defaultDisplay.rotation
)
BooleanSetting.SWAP_SCREEN.boolean = isEnabled
settings.saveSetting(BooleanSetting.SWAP_SCREEN, SettingsFile.FILE_NAME_CONFIG)
settings.update(BooleanSetting.SWAP_SCREEN, isEnabled)
SettingsFile.saveSetting(BooleanSetting.SWAP_SCREEN, settings)
}
fun cycleLayouts() {
val landscapeLayoutsToCycle = IntListSetting.LAYOUTS_TO_CYCLE.list;
val landscapeLayoutsToCycle = settings.get(IntListSetting.LAYOUTS_TO_CYCLE)
val landscapeValues =
if (landscapeLayoutsToCycle.isNotEmpty())
landscapeLayoutsToCycle.toIntArray()
@ -45,12 +45,12 @@ class ScreenAdjustmentUtil(
val portraitValues = context.resources.getIntArray(R.array.portraitValues)
if (NativeLibrary.isPortraitMode) {
val currentLayout = IntSetting.PORTRAIT_SCREEN_LAYOUT.int
val currentLayout = settings.get(IntSetting.PORTRAIT_SCREEN_LAYOUT)
val pos = portraitValues.indexOf(currentLayout)
val layoutOption = portraitValues[(pos + 1) % portraitValues.size]
changePortraitOrientation(layoutOption)
} else {
val currentLayout = IntSetting.SCREEN_LAYOUT.int
val currentLayout = settings.get(IntSetting.SCREEN_LAYOUT)
val pos = landscapeValues.indexOf(currentLayout)
val layoutOption = landscapeValues[(pos + 1) % landscapeValues.size]
changeScreenOrientation(layoutOption)
@ -58,30 +58,30 @@ class ScreenAdjustmentUtil(
}
fun changePortraitOrientation(layoutOption: Int) {
IntSetting.PORTRAIT_SCREEN_LAYOUT.int = layoutOption
settings.saveSetting(IntSetting.PORTRAIT_SCREEN_LAYOUT, SettingsFile.FILE_NAME_CONFIG)
settings.update(IntSetting.PORTRAIT_SCREEN_LAYOUT, layoutOption)
SettingsFile.saveSetting(IntSetting.PORTRAIT_SCREEN_LAYOUT, settings)
NativeLibrary.reloadSettings()
NativeLibrary.updateFramebuffer(NativeLibrary.isPortraitMode)
}
fun changeScreenOrientation(layoutOption: Int) {
IntSetting.SCREEN_LAYOUT.int = layoutOption
settings.saveSetting(IntSetting.SCREEN_LAYOUT, SettingsFile.FILE_NAME_CONFIG)
settings.update(IntSetting.SCREEN_LAYOUT, layoutOption)
SettingsFile.saveSetting(IntSetting.SCREEN_LAYOUT, settings)
NativeLibrary.reloadSettings()
NativeLibrary.updateFramebuffer(NativeLibrary.isPortraitMode)
}
fun changeActivityOrientation(orientationOption: Int) {
val activity = context as? Activity ?: return
IntSetting.ORIENTATION_OPTION.int = orientationOption
settings.saveSetting(IntSetting.ORIENTATION_OPTION, SettingsFile.FILE_NAME_CONFIG)
settings.update(IntSetting.ORIENTATION_OPTION, orientationOption)
SettingsFile.saveSetting(IntSetting.ORIENTATION_OPTION, settings)
activity.requestedOrientation = orientationOption
}
fun toggleScreenUpright() {
val uprightBoolean = BooleanSetting.UPRIGHT_SCREEN.boolean
BooleanSetting.UPRIGHT_SCREEN.boolean = !uprightBoolean
settings.saveSetting(BooleanSetting.UPRIGHT_SCREEN, SettingsFile.FILE_NAME_CONFIG)
val uprightBoolean = settings.get(BooleanSetting.UPRIGHT_SCREEN)
settings.update(BooleanSetting.UPRIGHT_SCREEN, !uprightBoolean)
SettingsFile.saveSetting(BooleanSetting.UPRIGHT_SCREEN, settings)
NativeLibrary.reloadSettings()
NativeLibrary.updateFramebuffer(NativeLibrary.isPortraitMode)

View File

@ -16,8 +16,9 @@ import android.view.SurfaceView
import android.view.WindowManager
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.features.settings.model.Settings
class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
class SecondaryDisplay(val context: Context, private val settings: Settings) : DisplayManager.DisplayListener {
private var pres: SecondaryDisplayPresentation? = null
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val vd: VirtualDisplay
@ -70,8 +71,7 @@ class SecondaryDisplay(val context: Context) : DisplayManager.DisplayListener {
// decide if we are going to the external display or the internal one
var display = getExternalDisplay(context)
if (display == null ||
IntSetting.SECONDARY_DISPLAY_LAYOUT.int == SecondaryDisplayLayout.NONE.int) {
if (display == null || settings.get(IntSetting.SECONDARY_DISPLAY_LAYOUT) == SecondaryDisplayLayout.NONE.int) {
display = vd.display
}

View File

@ -0,0 +1,374 @@
package org.citra.citra_emu.features.input
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.Settings
object GamepadHelper {
private const val BUTTON_NAME_L3 = "Button L3"
private const val BUTTON_NAME_R3 = "Button R3"
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
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_"))
fun getAxisName(axis: Int, direction: Int?): String = "Axis " + axis + direction?.let { if(it > 0) "+" else "-" }
private fun toTitleCase(raw: String): String =
raw.replace("_", " ").lowercase()
.split(" ").joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
private data class DefaultButtonMapping(
val setting: InputMappingSetting,
val hostKeyCode: Int
)
// Auto-map always sets inverted = false. Users needing inverted axes should remap manually.
private data class DefaultAxisMapping(
val setting: InputMappingSetting,
val hostAxis: Int,
val hostDirection: Int
)
private val xboxFaceButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_A, KeyEvent.KEYCODE_BUTTON_B),
DefaultButtonMapping(InputMappingSetting.BUTTON_B, KeyEvent.KEYCODE_BUTTON_A),
DefaultButtonMapping(InputMappingSetting.BUTTON_X, KeyEvent.KEYCODE_BUTTON_Y),
DefaultButtonMapping(InputMappingSetting.BUTTON_Y, KeyEvent.KEYCODE_BUTTON_X)
)
private val nintendoFaceButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_A, KeyEvent.KEYCODE_BUTTON_A),
DefaultButtonMapping(InputMappingSetting.BUTTON_B, KeyEvent.KEYCODE_BUTTON_B),
DefaultButtonMapping(InputMappingSetting.BUTTON_X, KeyEvent.KEYCODE_BUTTON_X),
DefaultButtonMapping(InputMappingSetting.BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y)
)
private val commonButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_L, KeyEvent.KEYCODE_BUTTON_L1),
DefaultButtonMapping(InputMappingSetting.BUTTON_R, KeyEvent.KEYCODE_BUTTON_R1),
DefaultButtonMapping(InputMappingSetting.BUTTON_ZL, KeyEvent.KEYCODE_BUTTON_L2),
DefaultButtonMapping(InputMappingSetting.BUTTON_ZR, KeyEvent.KEYCODE_BUTTON_R2),
DefaultButtonMapping(InputMappingSetting.BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_SELECT),
DefaultButtonMapping(InputMappingSetting.BUTTON_START, KeyEvent.KEYCODE_BUTTON_START)
)
private val dpadButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.DPAD_UP, KeyEvent.KEYCODE_DPAD_UP),
DefaultButtonMapping(InputMappingSetting.DPAD_DOWN, KeyEvent.KEYCODE_DPAD_DOWN),
DefaultButtonMapping(InputMappingSetting.DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT),
DefaultButtonMapping(InputMappingSetting.DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT)
)
private val stickAxisMappings = listOf(
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_LEFT, MotionEvent.AXIS_X, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_RIGHT, MotionEvent.AXIS_X, 1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_UP, MotionEvent.AXIS_Y, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_DOWN, MotionEvent.AXIS_Y, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_LEFT, MotionEvent.AXIS_Z, -1),
DefaultAxisMapping(InputMappingSetting.CSTICK_RIGHT, MotionEvent.AXIS_Z, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_UP, MotionEvent.AXIS_RZ, -1),
DefaultAxisMapping(InputMappingSetting.CSTICK_DOWN, MotionEvent.AXIS_RZ, 1)
)
private val dpadAxisMappings = listOf(
DefaultAxisMapping(InputMappingSetting.DPAD_UP, MotionEvent.AXIS_HAT_Y, -1),
DefaultAxisMapping(InputMappingSetting.DPAD_DOWN, MotionEvent.AXIS_HAT_Y, 1),
DefaultAxisMapping(InputMappingSetting.DPAD_LEFT, MotionEvent.AXIS_HAT_X, -1),
DefaultAxisMapping(InputMappingSetting.DPAD_RIGHT, MotionEvent.AXIS_HAT_X, 1)
)
// 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
// 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(InputMappingSetting.BUTTON_A, KeyEvent.KEYCODE_BUTTON_B),
DefaultButtonMapping(InputMappingSetting.BUTTON_B, KeyEvent.KEYCODE_BUTTON_A),
DefaultButtonMapping(InputMappingSetting.BUTTON_X, KeyEvent.KEYCODE_BUTTON_X),
DefaultButtonMapping(InputMappingSetting.BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y)
)
// Joy-Con D-pad: uses Linux scan codes because Android reports BTN_DPAD_* as KEYCODE_UNKNOWN
private val joyconDpadButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.DPAD_UP, LINUX_BTN_DPAD_UP),
DefaultButtonMapping(InputMappingSetting.DPAD_DOWN, LINUX_BTN_DPAD_DOWN),
DefaultButtonMapping(InputMappingSetting.DPAD_LEFT, LINUX_BTN_DPAD_LEFT),
DefaultButtonMapping(InputMappingSetting.DPAD_RIGHT, LINUX_BTN_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(InputMappingSetting.CIRCLEPAD_UP, MotionEvent.AXIS_Y, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_DOWN, MotionEvent.AXIS_Y, 1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_LEFT, MotionEvent.AXIS_X, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_RIGHT, MotionEvent.AXIS_X, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_UP, MotionEvent.AXIS_RY, -1),
DefaultAxisMapping(InputMappingSetting.CSTICK_DOWN, MotionEvent.AXIS_RY, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_LEFT, MotionEvent.AXIS_RX, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_RIGHT, MotionEvent.AXIS_RX, -1)
)
/**
* 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
}
fun clearAllBindings(settings: Settings) {
InputMappingSetting.values().forEach { settings.set(it, Input()) }
}
private fun applyBindings(
settings: Settings,
buttonMappings: List<DefaultButtonMapping>,
axisMappings: List<DefaultAxisMapping>
) {
buttonMappings.forEach { mapping ->
settings.set(mapping.setting, Input(key = mapping.hostKeyCode))
}
axisMappings.forEach { mapping ->
settings.set(
mapping.setting,
Input(
axis = mapping.hostAxis,
direction = mapping.hostDirection
)
)
}
}
/**
* Applies Joy-Con specific bindings: scan code D-pad, partial face button
* swap, and AXIS_RX/RY right stick.
*/
fun applyJoyConBindings(settings: Settings) {
applyBindings(
settings,
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(settings: Settings, 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(settings,buttonMappings, axisMappings)
}
/**
* Some controllers report extra button presses that can be ignored.
*/
fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
return if (isDualShock4(inputDevice)) {
// The two analog triggers generate analog motion events as well as a keycode.
// We always prefer to use the analog values, so throw away the button press
keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
} else false
}
/**
* Scale an axis to be zero-centered with a proper range.
*/
fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
if (isDualShock4(inputDevice)) {
// Android doesn't have correct mappings for this controller's triggers. It reports them
// as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
// Scale them to properly zero-centered with a range of [0.0, 1.0].
if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
return (value + 1) / 2.0f
}
} else if (isXboxOneWireless(inputDevice)) {
// Same as the DualShock 4, the mappings are missing.
if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
return (value + 1) / 2.0f
}
if (axis == MotionEvent.AXIS_GENERIC_1) {
// This axis is stuck at ~.5. Ignore it.
return 0.0f
}
} else if (isMogaPro2Hid(inputDevice)) {
// This controller has a broken axis that reports a constant value. Ignore it.
if (axis == MotionEvent.AXIS_GENERIC_1) {
return 0.0f
}
}
return value
}
private fun isDualShock4(inputDevice: InputDevice): Boolean {
// Sony DualShock 4 controller
return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
}
private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
// Microsoft Xbox One controller
return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
}
private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
// Moga Pro 2 HID
return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
}
data class JoystickComponent(val joystickType: Int, val isVertical: Boolean)
fun getJoystickComponent(buttonType: Int): JoystickComponent? = when (buttonType) {
NativeLibrary.ButtonType.STICK_LEFT_UP,
NativeLibrary.ButtonType.STICK_LEFT_DOWN -> JoystickComponent(NativeLibrary.ButtonType.STICK_LEFT, true)
NativeLibrary.ButtonType.STICK_LEFT_LEFT,
NativeLibrary.ButtonType.STICK_LEFT_RIGHT -> JoystickComponent(NativeLibrary.ButtonType.STICK_LEFT, false)
NativeLibrary.ButtonType.STICK_C_UP,
NativeLibrary.ButtonType.STICK_C_DOWN -> JoystickComponent(NativeLibrary.ButtonType.STICK_C, true)
NativeLibrary.ButtonType.STICK_C_LEFT,
NativeLibrary.ButtonType.STICK_C_RIGHT -> JoystickComponent(NativeLibrary.ButtonType.STICK_C, false)
else -> null
}
val buttonKeys = listOf(
InputMappingSetting.BUTTON_A,
InputMappingSetting.BUTTON_B,
InputMappingSetting.BUTTON_X,
InputMappingSetting.BUTTON_Y,
InputMappingSetting.BUTTON_SELECT,
InputMappingSetting.BUTTON_START,
InputMappingSetting.BUTTON_HOME
)
val buttonTitles = listOf(
R.string.button_a,
R.string.button_b,
R.string.button_x,
R.string.button_y,
R.string.button_select,
R.string.button_start,
R.string.button_home
)
val circlePadKeys = listOf(
InputMappingSetting.CIRCLEPAD_UP,
InputMappingSetting.CIRCLEPAD_DOWN,
InputMappingSetting.CIRCLEPAD_LEFT,
InputMappingSetting.CIRCLEPAD_RIGHT
)
val cStickKeys = listOf(
InputMappingSetting.CSTICK_UP,
InputMappingSetting.CSTICK_DOWN,
InputMappingSetting.CSTICK_LEFT,
InputMappingSetting.CSTICK_RIGHT
)
val dPadButtonKeys = listOf(
InputMappingSetting.DPAD_UP,
InputMappingSetting.DPAD_DOWN,
InputMappingSetting.DPAD_LEFT,
InputMappingSetting.DPAD_RIGHT
)
val dPadTitles = listOf(
R.string.direction_up,
R.string.direction_down,
R.string.direction_left,
R.string.direction_right
)
val axisTitles = dPadTitles
val triggerKeys = listOf(
InputMappingSetting.BUTTON_L,
InputMappingSetting.BUTTON_R,
InputMappingSetting.BUTTON_ZL,
InputMappingSetting.BUTTON_ZR
)
val triggerTitles = listOf(
R.string.button_l,
R.string.button_r,
R.string.button_zl,
R.string.button_zr
)
val hotKeys = listOf(
InputMappingSetting.HOTKEY_ENABLE,
InputMappingSetting.HOTKEY_SWAP,
InputMappingSetting.HOTKEY_CYCLE_LAYOUT,
InputMappingSetting.HOTKEY_CLOSE_GAME,
InputMappingSetting.HOTKEY_PAUSE_OR_RESUME,
InputMappingSetting.HOTKEY_QUICKSAVE,
InputMappingSetting.HOTKEY_QUICKLOAD,
InputMappingSetting.HOTKEY_TURBO_LIMIT
)
val hotkeyTitles = listOf(
R.string.controller_hotkey_enable_button,
R.string.emulation_swap_screens,
R.string.emulation_cycle_landscape_layouts,
R.string.emulation_close_game,
R.string.emulation_toggle_pause,
R.string.emulation_quicksave,
R.string.emulation_quickload,
R.string.turbo_limit_hotkey
)
}

View File

@ -2,7 +2,7 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.hotkeys
package org.citra.citra_emu.features.input
enum class Hotkey(val button: Int) {
SWAP_SCREEN(10001),

View File

@ -2,7 +2,7 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.hotkeys
package org.citra.citra_emu.features.input
import android.content.Context
import android.view.KeyEvent
@ -16,27 +16,30 @@ import org.citra.citra_emu.utils.TurboHelper
import org.citra.citra_emu.display.ScreenAdjustmentUtil
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.features.settings.model.Settings
import kotlin.math.abs
class HotkeyUtility(
private val screenAdjustmentUtil: ScreenAdjustmentUtil,
private val context: Context
private val context: Context,
private val settings: Settings
) {
private val hotkeyButtons = Hotkey.entries.map { it.button }
private var hotkeyIsEnabled = false
var hotkeyIsPressed = false
private val currentlyPressedButtons = mutableSetOf<Int>()
/** Store which axis directions are currently pressed as (axis, direction) pairs. */
private val pressedAxisDirections = HashSet<Pair<Int, Int>>() // (outAxis, outDir)
fun handleKeyPress(keyEvent: KeyEvent): Boolean {
fun handleKeyPress(key: Int, descriptor: String): Boolean {
var handled = false
val buttonSet = InputBindingSetting.getButtonSet(keyEvent)
val enableButton =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
.getString(Settings.HOTKEY_ENABLE, "")
val buttonSet = settings.inputMappingManager.getOutButtonsForKey(key)
val axisSet = settings.inputMappingManager.getOutAxesForKey(key)
val enableButtonMapped = settings.inputMappingManager.getMappingForButton(Hotkey.ENABLE.button) != null
val thisKeyIsEnableButton = buttonSet.contains(Hotkey.ENABLE.button)
val thisKeyIsHotkey =
!thisKeyIsEnableButton && Hotkey.entries.any { buttonSet.contains(it.button) }
hotkeyIsEnabled = hotkeyIsEnabled || enableButton == "" || thisKeyIsEnableButton
hotkeyIsEnabled = hotkeyIsEnabled || !enableButtonMapped || thisKeyIsEnableButton
// Now process all internal buttons associated with this keypress
for (button in buttonSet) {
@ -57,19 +60,23 @@ class HotkeyUtility(
// the normal key event.
if (!thisKeyIsHotkey || !hotkeyIsEnabled) {
handled = NativeLibrary.onGamePadEvent(
keyEvent.device.descriptor,
descriptor,
button,
NativeLibrary.ButtonState.PRESSED
) || handled
}
}
}
// Handle axes in helper functions
updateAxisStateForKey(axisSet,true)
handled = sendAxisState(descriptor, axisSet) || handled
return handled
}
fun handleKeyRelease(keyEvent: KeyEvent): Boolean {
fun handleKeyRelease(key: Int, descriptor: String): Boolean {
var handled = false
val buttonSet = InputBindingSetting.getButtonSet(keyEvent)
val buttonSet = settings.inputMappingManager.getOutButtonsForKey(key)
val axisSet = settings.inputMappingManager.getOutAxesForKey(key)
val thisKeyIsEnableButton = buttonSet.contains(Hotkey.ENABLE.button)
val thisKeyIsHotkey =
!thisKeyIsEnableButton && Hotkey.entries.any { buttonSet.contains(it.button) }
@ -95,7 +102,7 @@ class HotkeyUtility(
)
) {
handled = NativeLibrary.onGamePadEvent(
keyEvent.device.descriptor,
descriptor,
button,
NativeLibrary.ButtonState.RELEASED
) || handled
@ -103,6 +110,8 @@ class HotkeyUtility(
}
}
}
updateAxisStateForKey(axisSet,false)
handled = sendAxisState(descriptor, axisSet) || handled
return handled
}
@ -112,7 +121,7 @@ class HotkeyUtility(
Hotkey.CYCLE_LAYOUT.button -> screenAdjustmentUtil.cycleLayouts()
Hotkey.CLOSE_GAME.button -> EmulationLifecycleUtil.closeGame()
Hotkey.PAUSE_OR_RESUME.button -> EmulationLifecycleUtil.pauseOrResume()
Hotkey.TURBO_LIMIT.button -> TurboHelper.toggleTurbo(true)
Hotkey.TURBO_LIMIT.button -> TurboHelper.toggleTurbo(true, settings)
Hotkey.QUICKSAVE.button -> {
NativeLibrary.saveState(NativeLibrary.QUICKSAVE_SLOT)
Toast.makeText(
@ -141,4 +150,42 @@ class HotkeyUtility(
hotkeyIsPressed = true
return true
}
private fun updateAxisStateForKey(axisSet: List<Pair<Int, Int>>, pressed: Boolean) {
axisSet.forEach { (outAxis, outDir) ->
if (pressed) pressedAxisDirections.add(Pair(outAxis, outDir))
else pressedAxisDirections.remove(Pair(outAxis, outDir))
}
}
/**
* Update axis state based on currently pressed buttons
*/
private fun sendAxisState(descriptor: String, affectedAxes: List<Pair<Int, Int>>): Boolean {
val stickAccumulator = HashMap<Int, Pair<Float, Float>>()
// to make sure that when both directions are released, we still get a return to 0, but only for
// axes that have been touched by buttons
affectedAxes.forEach { (outAxis, outDir) ->
val component = GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
stickAccumulator.putIfAbsent(component.joystickType, Pair(0f,0f))
}
pressedAxisDirections.forEach{ (outAxis, outDir) ->
val component = GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
val current =
stickAccumulator.getOrDefault(component.joystickType, Pair(0f, 0f))
// if opposite directions of the same stick are held, this will let them cancel each other out
stickAccumulator[component.joystickType] = if (component.isVertical) {
Pair(current.first, current.second + outDir)
} else {
Pair(current.first + outDir, current.second)
}
}
var handled = false
stickAccumulator.forEach { (joystickType, value) ->
handled = NativeLibrary.onGamePadMoveEvent(descriptor, joystickType, value.first, value.second) || handled
}
return handled
}
}

View File

@ -0,0 +1,16 @@
package org.citra.citra_emu.features.input
/**
* Stores information about a particular input
*/
data class Input(
val key: Int? = null,
val axis: Int? = null,
// +1 or -1
val direction: Int? = null,
val threshold: Float? = null
) {
val empty: Boolean
get() = key == null && axis == null
}

View File

@ -0,0 +1,149 @@
package org.citra.citra_emu.features.input
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.Settings
class InputMappingManager() {
/**
* input keys are represented by a single int, and can be mapped to 3ds buttons or axis directions
* input axes are represented by an <axis, direction> pair, where direction is either 1 or -1
* and can be mapped to output axes or an output button
* output keys are a single int
* output axes are also a pair
*
* For now, we are only allowing one output *axis* per input, but the code is designed to change
* that if necessary. There can be more than on output *button* per input because hotkeys are treated
* as buttons and it is possible to map an input to both a 3ds button and a hotkey.
*/
private val keyToOutButtons = HashMap<Int, MutableList<Int>>()
private val keyToOutAxes = HashMap<Int, MutableList<Pair<Int, Int>>>()
private val axisToOutAxes = HashMap<Pair<Int, Int>, MutableList<Pair<Int, Int>>>()
private val axisToOutButtons = HashMap<Pair<Int, Int>, MutableList<Int>>()
private val outAxisToMapping = HashMap<Pair<Int, Int>, Input>()
private val buttonToMapping = HashMap<Int, Input>()
/** Rebuilds the input maps from the given settings instance */
fun rebuild(settings: Settings) {
clear()
InputMappingSetting.values().forEach { setting ->
val mapping = settings.get(setting) ?: return@forEach
register(setting, mapping)
}
}
fun clear() {
axisToOutButtons.clear()
buttonToMapping.clear()
axisToOutAxes.clear()
outAxisToMapping.clear()
keyToOutButtons.clear()
keyToOutAxes.clear()
}
/** Rebind a particular setting */
fun rebind(setting: InputMappingSetting, newMapping: Input?) {
clearMapping(setting)
if (newMapping != null) register(setting, newMapping)
}
/** Clear a mapping from all hashmaps */
fun clearMapping(setting: InputMappingSetting) {
val outPair = if (setting.outAxis != null && setting.outDirection != null) Pair(
setting.outAxis,
setting.outDirection
) else null
val oldMapping = if (setting.outKey != null) {
buttonToMapping.get(setting.outKey)
} else if (setting.outAxis != null && setting.outDirection != null) {
outAxisToMapping.get(Pair(setting.outAxis, setting.outDirection))
} else {
null
}
val oldPair = if (oldMapping?.axis != null && oldMapping?.direction != null) Pair(
oldMapping.axis,
oldMapping.direction
) else null
// if our old mapping was a key, remove its binds
if (oldMapping?.key != null) {
keyToOutButtons[oldMapping.key]?.remove(setting.outKey)
if (outPair != null) {
keyToOutAxes[oldMapping.key]?.remove(outPair)
}
}
// if our old mapping was an axis, remove its binds
if (oldPair != null) {
if (setting.outAxis != null && setting.outDirection != null)
axisToOutAxes[oldPair]?.remove(outPair)
if (setting.outKey != null)
axisToOutButtons[oldPair]?.remove(setting.outKey)
}
// remove the reverse binds
if (setting.outKey != null) {
buttonToMapping.remove(setting.outKey)
}
if (outPair != null) {
outAxisToMapping.remove(outPair)
}
}
/**
* Add a single item to the maps based on the value of the InputMapping and the InputMappingSetting
*/
private fun register(setting: InputMappingSetting, mapping: Input) {
val outPair = if (setting.outAxis != null && setting.outDirection != null) Pair(
setting.outAxis,
setting.outDirection
) else null
val inPair = if (mapping.axis != null && mapping.direction != null) Pair(
mapping.axis,
mapping.direction
) else null
if (setting.outKey != null) {
if (mapping.key != null)
keyToOutButtons.getOrPut(mapping.key, { mutableListOf() }).add(setting.outKey)
if (inPair != null) {
if (setting.outKey != null)
axisToOutButtons.getOrPut(inPair, { mutableListOf() }).add(setting.outKey)
}
buttonToMapping[setting.outKey] = mapping
}
if (outPair != null) {
if (mapping.key != null)
keyToOutAxes.getOrPut(mapping.key, { mutableListOf() })
.add(outPair)
if (inPair != null)
axisToOutAxes.getOrPut(inPair, { mutableListOf() })
.add(outPair)
outAxisToMapping[outPair] = mapping
}
}
fun getOutAxesForAxis(pair: Pair<Int, Int>): List<Pair<Int, Int>> =
axisToOutAxes[pair] ?: emptyList()
fun getOutButtonsForAxis(pair: Pair<Int, Int>): List<Int> =
axisToOutButtons[pair] ?: emptyList()
fun getMappingForOutAxis(pair: Pair<Int, Int>): Input? =
outAxisToMapping[pair]
fun getOutButtonsForKey(keyCode: Int): List<Int> =
keyToOutButtons[keyCode] ?: emptyList()
fun getOutAxesForKey(keyCode: Int): List<Pair<Int, Int>> =
keyToOutAxes[keyCode] ?: emptyList()
fun getMappingForButton(outKey: Int): Input? =
buttonToMapping[outKey]
}

View File

@ -138,4 +138,38 @@ object SettingKeys {
external fun android_hide_images(): String
external fun screen_orientation(): String
external fun performance_overlay_position(): String
external fun button_a(): String
external fun button_b(): String
external fun button_x(): String
external fun button_y(): String
external fun button_home(): String
external fun circlepad_up(): String
external fun circlepad_down(): String
external fun circlepad_left(): String
external fun circlepad_right(): String
external fun button_r(): String
external fun button_l(): String
external fun button_zr(): String
external fun button_zl(): String
external fun button_start(): String
external fun button_select(): String
external fun dpad_up(): String
external fun dpad_down(): String
external fun dpad_left(): String
external fun dpad_right(): String
external fun cstick_up(): String
external fun cstick_down(): String
external fun cstick_left(): String
external fun cstick_right(): String
external fun hotkey_cycle_layout(): String
external fun hotkey_close(): String
external fun hotkey_swap(): String
external fun hotkey_pause_resume(): String
external fun hotkey_quicksave(): String
external fun hotkey_turbo_limit(): String
external fun hotkey_quickload(): String
external fun hotkey_enable(): String
}

View File

@ -1,9 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
interface AbstractBooleanSetting : AbstractSetting {
var boolean: Boolean
}

View File

@ -1,9 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
interface AbstractFloatSetting : AbstractSetting {
var float: Float
}

View File

@ -1,9 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
interface AbstractIntSetting : AbstractSetting {
var int: Int
}

View File

@ -1,9 +0,0 @@
// 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
interface AbstractListSetting<E> : AbstractSetting {
var list: List<E>
}

View File

@ -1,13 +1,16 @@
// 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
interface AbstractSetting {
val key: String?
val section: String?
interface AbstractSetting<T> {
val key: String
val section: String
val defaultValue: T
val isRuntimeEditable: Boolean
val valueAsString: String
val defaultValue: Any
fun valueToString(value: T): String = value.toString()
fun valueFromString(string: String): T?
}

View File

@ -1,9 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
interface AbstractShortSetting : AbstractSetting {
var short: Short
}

View File

@ -1,9 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
interface AbstractStringSetting : AbstractSetting {
var string: String
}

View File

@ -10,7 +10,7 @@ enum class BooleanSetting(
override val key: String,
override val section: String,
override val defaultValue: Boolean
) : AbstractBooleanSetting {
) : AbstractSetting<Boolean> {
EXPAND_TO_CUTOUT_AREA(SettingKeys.expand_to_cutout_area(), Settings.SECTION_LAYOUT, false),
SPIRV_SHADER_GEN(SettingKeys.spirv_shader_gen(), Settings.SECTION_RENDERER, true),
ASYNC_SHADERS(SettingKeys.async_shader_compilation(), Settings.SECTION_RENDERER, false),
@ -58,10 +58,13 @@ enum class BooleanSetting(
APPLY_REGION_FREE_PATCH(SettingKeys.apply_region_free_patch(), Settings.SECTION_SYSTEM, true),
USE_INTEGER_SCALING(SettingKeys.use_integer_scaling(), Settings.SECTION_RENDERER, false);
override var boolean: Boolean = defaultValue
override val valueAsString: String
get() = boolean.toString()
override fun valueFromString(string: String): Boolean? {
return when (string.trim().lowercase()) {
"1", "true" -> true
"0", "false" -> false
else -> null
}
}
override val isRuntimeEditable: Boolean
get() {
@ -98,7 +101,5 @@ enum class BooleanSetting(
fun from(key: String): BooleanSetting? =
BooleanSetting.values().firstOrNull { it.key == key }
fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue }
}
}
}

View File

@ -9,18 +9,25 @@ import org.citra.citra_emu.features.settings.SettingKeys
enum class FloatSetting(
override val key: String,
override val section: String,
override val defaultValue: Float
) : AbstractFloatSetting {
override val defaultValue: Float,
val scale:Int = 1
) : AbstractSetting<Float> {
LARGE_SCREEN_PROPORTION(SettingKeys.large_screen_proportion(),Settings.SECTION_LAYOUT,2.25f),
SECOND_SCREEN_OPACITY(SettingKeys.custom_second_layer_opacity(), Settings.SECTION_RENDERER, 100f),
BACKGROUND_RED(SettingKeys.bg_red(), Settings.SECTION_RENDERER, 0f),
BACKGROUND_BLUE(SettingKeys.bg_blue(), Settings.SECTION_RENDERER, 0f),
BACKGROUND_GREEN(SettingKeys.bg_green(), Settings.SECTION_RENDERER, 0f);
BACKGROUND_RED(SettingKeys.bg_red(), Settings.SECTION_RENDERER, 0f, 255),
BACKGROUND_BLUE(SettingKeys.bg_blue(), Settings.SECTION_RENDERER, 0f, 255),
BACKGROUND_GREEN(SettingKeys.bg_green(), Settings.SECTION_RENDERER, 0f, 255),
AUDIO_VOLUME(SettingKeys.volume(), Settings.SECTION_AUDIO, 100f, 100);
override var float: Float = defaultValue
// valueFromString reads raw setting from file, scales up for UI
override fun valueFromString(string: String): Float? {
return string.toFloatOrNull()?.times(scale)
}
override val valueAsString: String
get() = float.toString()
// valueToString scales back down to raw for file
override fun valueToString(value: Float): String {
return (value / scale).toString()
}
override val isRuntimeEditable: Boolean
get() {
@ -36,7 +43,5 @@ enum class FloatSetting(
private val NOT_RUNTIME_EDITABLE = emptyList<FloatSetting>()
fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key }
fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue }
}
}

View File

@ -0,0 +1,221 @@
// 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
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.features.input.Input
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.input.Hotkey
import org.citra.citra_emu.features.settings.SettingKeys
enum class InputMappingSetting(
override val key: String,
override val section: String,
override val defaultValue: Input = Input(),
val outKey: Int? = null,
val outAxis: Int? = null,
val outDirection: Int? = null
) : AbstractSetting<Input?> {
BUTTON_A(
SettingKeys.button_a(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_A
),
BUTTON_B(
SettingKeys.button_b(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_B
),
BUTTON_X(
SettingKeys.button_x(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_X
),
BUTTON_Y(
SettingKeys.button_y(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_Y
),
BUTTON_HOME(
SettingKeys.button_home(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_HOME
),
BUTTON_L(
SettingKeys.button_l(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.TRIGGER_L
),
BUTTON_R(
SettingKeys.button_r(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.TRIGGER_R
),
BUTTON_SELECT(
SettingKeys.button_select(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_SELECT
),
BUTTON_START(
SettingKeys.button_start(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_START
),
BUTTON_ZL(
SettingKeys.button_zl(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_ZL
),
BUTTON_ZR(
SettingKeys.button_zr(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_ZR
),
DPAD_UP(
SettingKeys.dpad_up(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_UP
),
DPAD_DOWN(
SettingKeys.dpad_down(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_DOWN
),
DPAD_LEFT(
SettingKeys.dpad_left(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_LEFT
),
DPAD_RIGHT(
SettingKeys.dpad_right(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_RIGHT
),
CIRCLEPAD_UP(
SettingKeys.circlepad_up(),
Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_UP,
outDirection = -1
),
CIRCLEPAD_DOWN(
SettingKeys.circlepad_down(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_DOWN, outDirection = 1
),
CIRCLEPAD_LEFT(
SettingKeys.circlepad_left(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_LEFT, outDirection = -1
),
CIRCLEPAD_RIGHT(
SettingKeys.circlepad_right(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_RIGHT, outDirection = 1
),
CSTICK_UP(
SettingKeys.cstick_up(),
Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_UP,
outDirection = -1
),
CSTICK_DOWN(
SettingKeys.cstick_down(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_DOWN, outDirection = 1
),
CSTICK_LEFT(
SettingKeys.cstick_left(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_LEFT, outDirection = -1
),
CSTICK_RIGHT(
SettingKeys.cstick_right(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_RIGHT, outDirection = 1
),
HOTKEY_CYCLE_LAYOUT(
SettingKeys.hotkey_cycle_layout(), Settings.SECTION_CONTROLS,
outKey = Hotkey.CYCLE_LAYOUT.button
),
HOTKEY_CLOSE_GAME(
SettingKeys.hotkey_close(), Settings.SECTION_CONTROLS,
outKey = Hotkey.CLOSE_GAME.button
),
HOTKEY_SWAP(
SettingKeys.hotkey_swap(), Settings.SECTION_CONTROLS,
outKey = Hotkey.SWAP_SCREEN.button
),
HOTKEY_PAUSE_OR_RESUME(
SettingKeys.hotkey_pause_resume(), Settings.SECTION_CONTROLS,
outKey = Hotkey.PAUSE_OR_RESUME.button
),
HOTKEY_QUICKSAVE(
SettingKeys.hotkey_quicksave(), Settings.SECTION_CONTROLS,
outKey = Hotkey.QUICKSAVE.button
),
HOTKEY_TURBO_LIMIT(
SettingKeys.hotkey_turbo_limit(), Settings.SECTION_CONTROLS,
outKey = Hotkey.TURBO_LIMIT.button
),
HOTKEY_QUICKLOAD(
SettingKeys.hotkey_quickload(), Settings.SECTION_CONTROLS,
outKey = Hotkey.QUICKLOAD.button
),
HOTKEY_ENABLE(
SettingKeys.hotkey_enable(), Settings.SECTION_CONTROLS,
outKey = Hotkey.ENABLE.button
);
/** Parse a configuration string into an input binding */
override fun valueFromString(string: String): Input {
if (string.isBlank()) return defaultValue
val params = string.split(",")
.mapNotNull { part ->
val split = part.split(":", limit = 2)
if (split.size < 2) null else split[0] to split[1]
}.toMap()
if (params["engine"] != "gamepad") return defaultValue
return Input(
key = params["code"]?.toIntOrNull(),
axis = params["axis"]?.toIntOrNull(),
direction = params["direction"]?.toIntOrNull(),
threshold = params["threshold"]?.toFloatOrNull(),
).takeIf { it.key != null || it.axis != null } ?: defaultValue
}
/** Create a configuration string from an input binding */
override fun valueToString(binding: Input?): String {
binding ?: return ""
if (binding.empty) return ""
val ret = "engine:gamepad"
return ret + when {
binding.key != null -> ",code:${binding.key}"
binding.axis != null -> buildString {
append(",axis:${binding.axis}")
binding.threshold?.let { append(",threshold:$it") }
binding.direction?.let { append(",direction:$it") }
}
else -> ""
}
}
/** What will display is different from what is saved in this case */
fun displayValue(binding: Input?): String {
if (binding?.key != null) {
return GamepadHelper.getButtonName(binding.key)
} else if (binding?.axis != null) {
return GamepadHelper.getAxisName(binding.axis, binding.direction)
} else {
return ""
}
}
override val isRuntimeEditable: Boolean = true
companion object {
fun from(key: String): InputMappingSetting? = values().firstOrNull { it.key == key }
}
}

View File

@ -9,28 +9,17 @@ enum class IntListSetting(
override val section: String,
override val defaultValue: List<Int>,
val canBeEmpty: Boolean = true
) : AbstractListSetting<Int> {
) : AbstractSetting<List<Int>> {
LAYOUTS_TO_CYCLE("layouts_to_cycle", Settings.SECTION_LAYOUT, listOf(0, 1, 2, 3, 4, 5), canBeEmpty = false);
private var backingList: List<Int> = defaultValue
private var lastValidList : List<Int> = defaultValue
override var list: List<Int>
get() = backingList
set(value) {
if (!canBeEmpty && value.isEmpty()) {
backingList = lastValidList
} else {
backingList = value
lastValidList = value
}
}
override val valueAsString: String
get() = list.joinToString()
override fun valueToString(value: List<Int>): String = value.joinToString()
override fun valueFromString(string: String): List<Int>? {
return string.split(",")
.mapNotNull { it.trim().toIntOrNull() }
.takeIf { canBeEmpty || it.isNotEmpty() }
}
override val isRuntimeEditable: Boolean
get() {
for (setting in NOT_RUNTIME_EDITABLE) {
@ -46,7 +35,5 @@ enum class IntListSetting(
fun from(key: String): IntListSetting? =
values().firstOrNull { it.key == key }
fun clear() = values().forEach { it.list = it.defaultValue }
}
}

View File

@ -10,7 +10,7 @@ enum class IntSetting(
override val key: String,
override val section: String,
override val defaultValue: Int
) : AbstractIntSetting {
) : AbstractSetting<Int> {
FRAME_LIMIT(SettingKeys.frame_limit(), Settings.SECTION_RENDERER, 100),
EMULATED_REGION(SettingKeys.region_value(), Settings.SECTION_SYSTEM, -1),
INIT_CLOCK(SettingKeys.init_clock(), Settings.SECTION_SYSTEM, 0),
@ -50,7 +50,6 @@ enum class IntSetting(
CPU_CLOCK_SPEED(SettingKeys.cpu_clock_percentage(), Settings.SECTION_CORE, 100),
TEXTURE_FILTER(SettingKeys.texture_filter(), Settings.SECTION_RENDERER, 0),
TEXTURE_SAMPLING(SettingKeys.texture_sampling(), Settings.SECTION_RENDERER, 0),
USE_FRAME_LIMIT(SettingKeys.use_frame_limit(), Settings.SECTION_RENDERER, 1),
DELAY_RENDER_THREAD_US(SettingKeys.delay_game_render_thread_us(), Settings.SECTION_RENDERER, 0),
ORIENTATION_OPTION(SettingKeys.screen_orientation(), Settings.SECTION_LAYOUT, 2),
TURBO_LIMIT(SettingKeys.turbo_limit(), Settings.SECTION_CORE, 200),
@ -58,10 +57,15 @@ enum class IntSetting(
RENDER_3D_WHICH_DISPLAY(SettingKeys.render_3d_which_display(),Settings.SECTION_RENDERER,0),
ASPECT_RATIO(SettingKeys.aspect_ratio(), Settings.SECTION_LAYOUT, 0);
override var int: Int = defaultValue
override val valueAsString: String
get() = int.toString()
override fun valueFromString(string: String): Int? {
return string.toIntOrNull() ?: when (string.trim().lowercase()) {
"true" -> 1
"false" -> 0
else -> null
}
}
override val isRuntimeEditable: Boolean
get() {
@ -83,6 +87,5 @@ enum class IntSetting(
fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key }
fun clear() = IntSetting.values().forEach { it.int = it.defaultValue }
}
}
}

View File

@ -1,43 +0,0 @@
// 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
import org.citra.citra_emu.features.settings.SettingKeys
enum class ScaledFloatSetting(
override val key: String,
override val section: String,
override val defaultValue: Float,
val scale: Int
) : AbstractFloatSetting {
AUDIO_VOLUME(SettingKeys.volume(), Settings.SECTION_AUDIO, 1.0f, 100);
override var float: Float = defaultValue
get() = field * scale
set(value) {
field = value / scale
}
override val valueAsString: String get() = (float / scale).toString()
override val isRuntimeEditable: Boolean
get() {
for (setting in NOT_RUNTIME_EDITABLE) {
if (setting == this) {
return false
}
}
return true
}
companion object {
private val NOT_RUNTIME_EDITABLE = emptyList<ScaledFloatSetting>()
fun from(key: String): ScaledFloatSetting? =
ScaledFloatSetting.values().firstOrNull { it.key == key }
fun clear() = ScaledFloatSetting.values().forEach { it.float = it.defaultValue * it.scale }
}
}

View File

@ -1,38 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
/**
* A semantically-related group of Settings objects. These Settings are
* internally stored as a HashMap.
*/
class SettingSection(val name: String) {
val settings = HashMap<String, AbstractSetting>()
/**
* Convenience method; inserts a value directly into the backing HashMap.
*
* @param setting The Setting to be inserted.
*/
fun putSetting(setting: AbstractSetting) {
settings[setting.key!!] = setting
}
/**
* Convenience method; gets a value directly from the backing HashMap.
*
* @param key Used to retrieve the Setting.
* @return A Setting object (you should probably cast this before using)
*/
fun getSetting(key: String): AbstractSetting? {
return settings[key]
}
fun mergeSection(settingSection: SettingSection) {
for (setting in settingSection.settings.values) {
putSetting(setting)
}
}
}

View File

@ -4,99 +4,111 @@
package org.citra.citra_emu.features.settings.model
import android.text.TextUtils
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.ui.SettingsActivityView
import org.citra.citra_emu.features.input.Input
import org.citra.citra_emu.features.input.InputMappingManager
import org.citra.citra_emu.features.settings.utils.SettingsFile
import java.util.TreeMap
class Settings {
private var gameId: String? = null
private val globalValues = HashMap<String, Any>()
private val perGameOverrides = HashMap<String, Any>()
var isLoaded = false
val inputMappingManager = InputMappingManager()
var gameId: String? = null
fun isPerGame(): Boolean = gameId != null && gameId != ""
fun <T> get(setting: AbstractSetting<T>): T {
@Suppress("UNCHECKED_CAST")
return (perGameOverrides[setting.key]
?: globalValues[setting.key]
?: setting.defaultValue) as T
}
fun <T> getGlobal(setting: AbstractSetting<T>): T {
@Suppress("UNCHECKED_CAST")
return (globalValues[setting.key] ?: setting.defaultValue) as T
}
/** Sets the global value specifically */
fun <T> setGlobal(setting: AbstractSetting<T>, value: T) {
globalValues[setting.key] = value as Any
// only update the InputMapping if this global setting actually is in effect now
if (setting is InputMappingSetting && !hasOverride(setting)) {
inputMappingManager.rebind(setting, value as? Input)
}
}
/** Sets the override specifically */
fun <T> setOverride(setting: AbstractSetting<T>, value: T) {
perGameOverrides[setting.key] = value as Any
if (setting is InputMappingSetting) {
inputMappingManager.rebind(setting, value as? Input)
}
}
/** Sets the per-game or global setting based on whether this file has ANY per-game setting.
* This should be used by the Custom Settings Activity
*/
fun <T> set(setting: AbstractSetting<T>, value: T) {
if (isPerGame()) setOverride(setting, value) else setGlobal(setting, value)
}
/**
* A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null
* when getting a key not already in the map
* Updates an existing setting honoring whether this particular setting is *currently* global or local.
* This should be used by the Quick Menu
*/
class SettingsSectionMap : HashMap<String, SettingSection?>() {
override operator fun get(key: String): SettingSection? {
if (!super.containsKey(key)) {
val section = SettingSection(key)
super.put(key, section)
return section
}
return super.get(key)
fun <T> update(setting: AbstractSetting<T>, value: T) {
if (hasOverride(setting)) setOverride(setting, value) else setGlobal(setting, value)
}
/** Merge the globals from other into the current settings. Merge per-game if game id is the same. */
fun mergeSettings(other: Settings) {
other.globalValues.forEach{ (key, value) ->
globalValues[key] = value
}
if (gameId != other.gameId) return
perGameOverrides.clear()
other.perGameOverrides.forEach{ (key, value) ->
perGameOverrides[key] = value
}
inputMappingManager.rebuild(this)
}
fun <T> clearOverride(setting: AbstractSetting<T>) {
perGameOverrides.remove(setting.key)
if (setting is InputMappingSetting) {
inputMappingManager.rebind(setting, getGlobal(setting))
}
}
var sections: HashMap<String, SettingSection?> = SettingsSectionMap()
fun getSection(sectionName: String): SettingSection? {
return sections[sectionName]
fun hasOverride(setting: AbstractSetting<*>): Boolean {
return perGameOverrides.containsKey(setting.key)
}
val isEmpty: Boolean
get() = sections.isEmpty()
fun getAllOverrides(): Map<String, Any> = perGameOverrides.toMap()
fun loadSettings(view: SettingsActivityView? = null) {
sections = SettingsSectionMap()
loadCitraSettings(view)
if (!TextUtils.isEmpty(gameId)) {
loadCustomGameSettings(gameId!!, view)
}
isLoaded = true
fun getAllGlobal(): Map<String, Any> = globalValues.toMap()
fun clearAll() {
globalValues.clear()
perGameOverrides.clear()
inputMappingManager.clear()
}
private fun loadCitraSettings(view: SettingsActivityView?) {
for ((fileName) in configFileSectionsMap) {
sections.putAll(SettingsFile.readFile(fileName, view))
}
fun clearOverrides() {
perGameOverrides.clear()
inputMappingManager.rebuild(this)
}
private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView?) {
// Custom game settings
mergeSections(SettingsFile.readCustomGameSettings(gameId, view))
fun removePerGameSettings() {
clearOverrides()
gameId = null
}
private fun mergeSections(updatedSections: HashMap<String, SettingSection?>) {
for ((key, updatedSection) in updatedSections) {
if (sections.containsKey(key)) {
val originalSection = sections[key]
originalSection!!.mergeSection(updatedSection!!)
} else {
sections[key] = updatedSection
}
}
}
fun loadSettings(gameId: String, view: SettingsActivityView) {
this.gameId = gameId
loadSettings(view)
}
fun saveSettings(view: SettingsActivityView) {
if (TextUtils.isEmpty(gameId)) {
view.showToastMessage(
CitraApplication.appContext.getString(R.string.ini_saved),
false
)
for ((fileName, sectionNames) in configFileSectionsMap.entries) {
val iniSections = TreeMap<String, SettingSection?>()
for (section in sectionNames) {
iniSections[section] = sections[section]
}
SettingsFile.saveFile(fileName, iniSections, view)
}
} else {
// TODO: Implement per game settings
}
}
fun saveSetting(setting: AbstractSetting, filename: String) {
SettingsFile.saveFile(filename, setting)
}
companion object {
const val SECTION_CORE = "Core"
@ -115,115 +127,6 @@ class Settings {
const val SECTION_STORAGE = "Storage"
const val SECTION_MISC = "Miscellaneous"
const val KEY_BUTTON_A = "button_a"
const val KEY_BUTTON_B = "button_b"
const val KEY_BUTTON_X = "button_x"
const val KEY_BUTTON_Y = "button_y"
const val KEY_BUTTON_SELECT = "button_select"
const val KEY_BUTTON_START = "button_start"
const val KEY_BUTTON_HOME = "button_home"
const val KEY_BUTTON_UP = "button_up"
const val KEY_BUTTON_DOWN = "button_down"
const val KEY_BUTTON_LEFT = "button_left"
const val KEY_BUTTON_RIGHT = "button_right"
const val KEY_BUTTON_L = "button_l"
const val KEY_BUTTON_R = "button_r"
const val KEY_BUTTON_ZL = "button_zl"
const val KEY_BUTTON_ZR = "button_zr"
const val KEY_CIRCLEPAD_AXIS_VERTICAL = "circlepad_axis_vertical"
const val KEY_CIRCLEPAD_AXIS_HORIZONTAL = "circlepad_axis_horizontal"
const val KEY_CSTICK_AXIS_VERTICAL = "cstick_axis_vertical"
const val KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal"
const val KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical"
const val KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal"
const val HOTKEY_ENABLE = "hotkey_enable"
const val HOTKEY_SCREEN_SWAP = "hotkey_screen_swap"
const val HOTKEY_CYCLE_LAYOUT = "hotkey_toggle_layout"
const val HOTKEY_CLOSE_GAME = "hotkey_close_game"
const val HOTKEY_PAUSE_OR_RESUME = "hotkey_pause_or_resume_game"
const val HOTKEY_QUICKSAVE = "hotkey_quickload"
const val HOTKEY_QUICKlOAD = "hotkey_quickpause"
const val HOTKEY_TURBO_LIMIT = "hotkey_turbo_limit"
val buttonKeys = listOf(
KEY_BUTTON_A,
KEY_BUTTON_B,
KEY_BUTTON_X,
KEY_BUTTON_Y,
KEY_BUTTON_SELECT,
KEY_BUTTON_START,
KEY_BUTTON_HOME
)
val buttonTitles = listOf(
R.string.button_a,
R.string.button_b,
R.string.button_x,
R.string.button_y,
R.string.button_select,
R.string.button_start,
R.string.button_home
)
val circlePadKeys = listOf(
KEY_CIRCLEPAD_AXIS_VERTICAL,
KEY_CIRCLEPAD_AXIS_HORIZONTAL
)
val cStickKeys = listOf(
KEY_CSTICK_AXIS_VERTICAL,
KEY_CSTICK_AXIS_HORIZONTAL
)
val dPadAxisKeys = listOf(
KEY_DPAD_AXIS_VERTICAL,
KEY_DPAD_AXIS_HORIZONTAL
)
val dPadButtonKeys = listOf(
KEY_BUTTON_UP,
KEY_BUTTON_DOWN,
KEY_BUTTON_LEFT,
KEY_BUTTON_RIGHT
)
val axisTitles = listOf(
R.string.controller_axis_vertical,
R.string.controller_axis_horizontal
)
val dPadTitles = listOf(
R.string.direction_up,
R.string.direction_down,
R.string.direction_left,
R.string.direction_right
)
val triggerKeys = listOf(
KEY_BUTTON_L,
KEY_BUTTON_R,
KEY_BUTTON_ZL,
KEY_BUTTON_ZR
)
val triggerTitles = listOf(
R.string.button_l,
R.string.button_r,
R.string.button_zl,
R.string.button_zr
)
val hotKeys = listOf(
HOTKEY_ENABLE,
HOTKEY_SCREEN_SWAP,
HOTKEY_CYCLE_LAYOUT,
HOTKEY_CLOSE_GAME,
HOTKEY_PAUSE_OR_RESUME,
HOTKEY_QUICKSAVE,
HOTKEY_QUICKlOAD,
HOTKEY_TURBO_LIMIT
)
val hotkeyTitles = listOf(
R.string.controller_hotkey_enable_button,
R.string.emulation_swap_screens,
R.string.emulation_cycle_landscape_layouts,
R.string.emulation_close_game,
R.string.emulation_toggle_pause,
R.string.emulation_quicksave,
R.string.emulation_quickload,
R.string.turbo_limit_hotkey
)
// TODO: Move these in with the other setting keys in GenerateSettingKeys.cmake
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
const val PREF_MATERIAL_YOU = "MaterialYouTheme"
@ -234,20 +137,7 @@ class Settings {
private val configFileSectionsMap: MutableMap<String, List<String>> = HashMap()
init {
configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] =
listOf(
SECTION_CORE,
SECTION_SYSTEM,
SECTION_CAMERA,
SECTION_CONTROLS,
SECTION_RENDERER,
SECTION_LAYOUT,
SECTION_STORAGE,
SECTION_UTILITY,
SECTION_AUDIO,
SECTION_DEBUG
)
}
/** Stores the settings as a singleton available everywhere.*/
val settings = Settings()
}
}

View File

@ -1,4 +1,4 @@
// 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.
@ -7,5 +7,7 @@ package org.citra.citra_emu.features.settings.model
import androidx.lifecycle.ViewModel
class SettingsViewModel : ViewModel() {
// the settings activity primarily manipulates its own copy of the settings object
// syncing it with the active settings only when saving
val settings = Settings()
}

View File

@ -10,7 +10,7 @@ enum class StringSetting(
override val key: String,
override val section: String,
override val defaultValue: String
) : AbstractStringSetting {
) : AbstractSetting<String>{
INIT_TIME(SettingKeys.init_time(), Settings.SECTION_SYSTEM, "946731601"),
CAMERA_INNER_NAME(SettingKeys.camera_inner_name(), Settings.SECTION_CAMERA, "ndk"),
CAMERA_INNER_CONFIG(SettingKeys.camera_inner_config(), Settings.SECTION_CAMERA, "_front"),
@ -19,10 +19,7 @@ enum class StringSetting(
CAMERA_OUTER_RIGHT_NAME(SettingKeys.camera_outer_right_name(), Settings.SECTION_CAMERA, "ndk"),
CAMERA_OUTER_RIGHT_CONFIG(SettingKeys.camera_outer_right_config(), Settings.SECTION_CAMERA, "_back");
override var string: String = defaultValue
override val valueAsString: String
get() = string
override fun valueFromString(string: String) = string
override val isRuntimeEditable: Boolean
get() {
@ -47,6 +44,5 @@ enum class StringSetting(
fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key }
fun clear() = StringSetting.values().forEach { it.string = it.defaultValue }
}
}

View File

@ -1,33 +1,41 @@
// 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 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 DateTimeSetting(
setting: AbstractSetting?,
private val settings: Settings,
setting: AbstractSetting<String>?,
titleId: Int,
descriptionId: Int,
val key: String? = null,
private val defaultValue: String? = null,
override var isEnabled: Boolean = true
override var isEnabled: Boolean = true,
private val getValue: (()->String)? = null,
private val setValue: ((String)-> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_DATETIME_SETTING
@Suppress("UNCHECKED_CAST")
val value: String
get() = if (setting != null) {
val setting = setting as AbstractStringSetting
setting.string
} else {
defaultValue!!
}
get() = getValue?.invoke()
?: if (setting != null) {
settings.get(setting as AbstractSetting<String>)
} else {
defaultValue!!
}
fun setSelectedValue(datetime: String): AbstractStringSetting {
val stringSetting = setting as AbstractStringSetting
stringSetting.string = datetime
return stringSetting
@Suppress("UNCHECKED_CAST")
fun setSelectedValue(datetime: String) {
if (setValue != null) {
setValue(datetime)
}else {
val stringSetting = setting as AbstractSetting<String>
settings.set(stringSetting, datetime)
}
}
}

View File

@ -1,4 +1,4 @@
// 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.

View File

@ -4,593 +4,58 @@
package org.citra.citra_emu.features.settings.model.view
import android.content.Context
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
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.input.Input
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.Settings
class InputBindingSetting(
val abstractSetting: AbstractSetting,
val inputSetting: InputMappingSetting,
val settings: Settings,
titleId: Int
) : SettingsItem(abstractSetting, titleId, 0) {
private val context: Context get() = CitraApplication.appContext
private val preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(context)
var value: String
get() = preferences.getString(abstractSetting.key, "")!!
set(string) {
preferences.edit()
.putString(abstractSetting.key, string)
.apply()
}
/**
* Returns true if this key is for the 3DS Circle Pad
*/
fun isCirclePad(): Boolean =
when (abstractSetting.key) {
Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL,
Settings.KEY_CIRCLEPAD_AXIS_VERTICAL -> true
else -> false
}
/**
* Returns true if this key is for a horizontal axis for a 3DS analog stick or D-pad
*/
fun isHorizontalOrientation(): Boolean =
when (abstractSetting.key) {
Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL,
Settings.KEY_CSTICK_AXIS_HORIZONTAL,
Settings.KEY_DPAD_AXIS_HORIZONTAL -> true
else -> false
}
/**
* Returns true if this key is for the 3DS C-Stick
*/
fun isCStick(): Boolean =
when (abstractSetting.key) {
Settings.KEY_CSTICK_AXIS_HORIZONTAL,
Settings.KEY_CSTICK_AXIS_VERTICAL -> true
else -> false
}
/**
* Returns true if this key is for the 3DS D-Pad
*/
fun isDPad(): Boolean =
when (abstractSetting.key) {
Settings.KEY_DPAD_AXIS_HORIZONTAL,
Settings.KEY_DPAD_AXIS_VERTICAL -> 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.
*/
fun isTrigger(): Boolean =
when (abstractSetting.key) {
Settings.KEY_BUTTON_L,
Settings.KEY_BUTTON_R,
Settings.KEY_BUTTON_ZL,
Settings.KEY_BUTTON_ZR -> true
else -> false
}
/**
* Returns true if a gamepad axis can be used to map this key.
*/
fun isAxisMappingSupported(): Boolean {
return isCirclePad() || isCStick() || isDPad() || isTrigger()
}
/**
* Returns true if a gamepad button can be used to map this key.
*/
fun isButtonMappingSupported(): Boolean {
return !isAxisMappingSupported() || isTrigger()
}
/**
* Returns the Citra button code for the settings key.
*/
private val buttonCode: Int
get() =
when (abstractSetting.key) {
Settings.KEY_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
Settings.KEY_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
Settings.KEY_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
Settings.KEY_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
Settings.KEY_BUTTON_L -> NativeLibrary.ButtonType.TRIGGER_L
Settings.KEY_BUTTON_R -> NativeLibrary.ButtonType.TRIGGER_R
Settings.KEY_BUTTON_ZL -> NativeLibrary.ButtonType.BUTTON_ZL
Settings.KEY_BUTTON_ZR -> NativeLibrary.ButtonType.BUTTON_ZR
Settings.KEY_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_SELECT
Settings.KEY_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_START
Settings.KEY_BUTTON_HOME -> NativeLibrary.ButtonType.BUTTON_HOME
Settings.KEY_BUTTON_UP -> NativeLibrary.ButtonType.DPAD_UP
Settings.KEY_BUTTON_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
Settings.KEY_BUTTON_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
Settings.KEY_BUTTON_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
Settings.HOTKEY_ENABLE -> Hotkey.ENABLE.button
Settings.HOTKEY_SCREEN_SWAP -> Hotkey.SWAP_SCREEN.button
Settings.HOTKEY_CYCLE_LAYOUT -> Hotkey.CYCLE_LAYOUT.button
Settings.HOTKEY_CLOSE_GAME -> Hotkey.CLOSE_GAME.button
Settings.HOTKEY_PAUSE_OR_RESUME -> Hotkey.PAUSE_OR_RESUME.button
Settings.HOTKEY_QUICKSAVE -> Hotkey.QUICKSAVE.button
Settings.HOTKEY_QUICKlOAD -> Hotkey.QUICKLOAD.button
Settings.HOTKEY_TURBO_LIMIT -> Hotkey.TURBO_LIMIT.button
else -> -1
}
/**
* Returns the key used to lookup the reverse mapping for this key, which is used to cleanup old
* settings on re-mapping or clearing of a setting.
*/
private val reverseKey: String
) : SettingsItem(inputSetting, titleId, 0) {
val value: String
get() {
var reverseKey = "${INPUT_MAPPING_PREFIX}_ReverseMapping_${abstractSetting.key}"
if (isAxisMappingSupported() && !isTrigger()) {
// Triggers are the only axis-supported mappings without orientation
reverseKey += "_" + if (isHorizontalOrientation()) {
0
} else {
1
}
}
return reverseKey
val mapping = settings.get(inputSetting) ?: return ""
return inputSetting.displayValue(mapping)
}
/**
* Removes the old mapping for this key from the settings, e.g. on user clearing the setting.
*/
fun removeOldMapping() {
// Try remove all possible keys we wrote for this setting
val oldKey = preferences.getString(reverseKey, "")
if (oldKey != "") {
(setting as AbstractStringSetting).string = ""
preferences.edit()
.remove(abstractSetting.key) // Used for ui text
.remove(oldKey + "_GuestOrientation") // Used for axis orientation
.remove(oldKey + "_GuestButton") // Used for axis button
.remove(oldKey + "_Inverted") // used for axis inversion
.remove(reverseKey)
val buttonCodes = try {
preferences.getStringSet(oldKey, mutableSetOf<String>())!!.toMutableSet()
} catch (e: ClassCastException) {
// if this is an int pref, either old button or an axis, so just remove it
preferences.edit().remove(oldKey).apply()
return;
}
buttonCodes.remove(buttonCode.toString());
preferences.edit().putStringSet(oldKey,buttonCodes).apply()
}
/** Default value is cleared */
settings.set(inputSetting, inputSetting.defaultValue)
}
/**
* Helper function to write a gamepad button mapping for the setting.
*/
private fun writeButtonMapping(keyEvent: KeyEvent) {
val editor = preferences.edit()
val key = getInputButtonKey(keyEvent)
// Pull in all codes associated with this key
// Migrate from the old int preference if need be
val buttonCodes = InputBindingSetting.getButtonSet(keyEvent)
buttonCodes.add(buttonCode)
// Cleanup old mapping for this setting
removeOldMapping()
editor.putStringSet(key, buttonCodes.mapTo(mutableSetOf()) {it.toString()})
// Write next reverse mapping for future cleanup
editor.putString(reverseKey, key)
// Apply changes
editor.apply()
}
/**
* Helper function to write a gamepad axis mapping for the setting.
*/
private fun writeAxisMapping(axis: Int, value: Int, inverted: Boolean) {
// Cleanup old mapping
removeOldMapping()
// Write new mapping
preferences.edit()
.putInt(getInputAxisOrientationKey(axis), if (isHorizontalOrientation()) 0 else 1)
.putInt(getInputAxisButtonKey(axis), value)
.putBoolean(getInputAxisInvertedKey(axis),inverted)
// Write next reverse mapping for future cleanup
.putString(reverseKey, getInputAxisKey(axis))
.apply()
}
/**
* Saves the provided key input setting as an Android preference.
* Saves the provided key input to the setting
*
* @param keyEvent KeyEvent of this key press.
*/
fun onKeyInput(keyEvent: KeyEvent) {
if (!isButtonMappingSupported()) {
Toast.makeText(context, R.string.input_message_analog_only, Toast.LENGTH_LONG).show()
return
}
val code = translateEventToKeyId(keyEvent)
writeButtonMapping(keyEvent)
value = "${keyEvent.device.name}: ${getButtonName(code)}"
val mapping = Input(key = code)
settings.set(inputSetting, mapping)
}
/**
* Saves the provided motion input setting as an Android preference.
*
* @param device InputDevice from which the input event originated.
* @param motionRange MotionRange of the movement
* @param axisDir Either '-' or '+'
* @param axisDir Either -1 or 1
*/
fun onMotionInput(device: InputDevice, motionRange: MotionRange, axisDir: Char) {
if (!isAxisMappingSupported()) {
Toast.makeText(context, R.string.input_message_button_only, Toast.LENGTH_LONG).show()
return
}
val button = if (isCirclePad()) {
NativeLibrary.ButtonType.STICK_LEFT
} else if (isCStick()) {
NativeLibrary.ButtonType.STICK_C
} else if (isDPad()) {
NativeLibrary.ButtonType.DPAD
fun onMotionInput(motionRange: MotionRange, axisDir: Int) {
val mapping = Input(axis = motionRange.axis, direction = axisDir, threshold = 0.5f)
settings.set(inputSetting, mapping)
}
private fun translateEventToKeyId(event: KeyEvent): Int {
return if (event.keyCode == 0) {
event.scanCode
} else {
buttonCode
event.keyCode
}
// use UP (-) to map vertical, but use RIGHT (+) to map horizontal
val inverted = if (isHorizontalOrientation()) axisDir == '-' else axisDir == '+'
writeAxisMapping(motionRange.axis, button, inverted)
value = "Axis ${motionRange.axis}$axisDir"
}
override val type = TYPE_INPUT_BINDING
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<String> 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<DefaultButtonMapping>,
axisMappings: List<DefaultAxisMapping>
) {
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.
*/
private fun getButtonKey(buttonCode: Int): String =
when (buttonCode) {
NativeLibrary.ButtonType.BUTTON_A -> Settings.KEY_BUTTON_A
NativeLibrary.ButtonType.BUTTON_B -> Settings.KEY_BUTTON_B
NativeLibrary.ButtonType.BUTTON_X -> Settings.KEY_BUTTON_X
NativeLibrary.ButtonType.BUTTON_Y -> Settings.KEY_BUTTON_Y
NativeLibrary.ButtonType.TRIGGER_L -> Settings.KEY_BUTTON_L
NativeLibrary.ButtonType.TRIGGER_R -> Settings.KEY_BUTTON_R
NativeLibrary.ButtonType.BUTTON_ZL -> Settings.KEY_BUTTON_ZL
NativeLibrary.ButtonType.BUTTON_ZR -> Settings.KEY_BUTTON_ZR
NativeLibrary.ButtonType.BUTTON_SELECT -> Settings.KEY_BUTTON_SELECT
NativeLibrary.ButtonType.BUTTON_START -> Settings.KEY_BUTTON_START
NativeLibrary.ButtonType.BUTTON_HOME -> Settings.KEY_BUTTON_HOME
NativeLibrary.ButtonType.DPAD_UP -> Settings.KEY_BUTTON_UP
NativeLibrary.ButtonType.DPAD_DOWN -> Settings.KEY_BUTTON_DOWN
NativeLibrary.ButtonType.DPAD_LEFT -> Settings.KEY_BUTTON_LEFT
NativeLibrary.ButtonType.DPAD_RIGHT -> Settings.KEY_BUTTON_RIGHT
else -> ""
}
/**
* Get the mutable set of int button values this key should map to given an event
*/
fun getButtonSet(keyCode: KeyEvent):MutableSet<Int> {
val key = getInputButtonKey(keyCode)
val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
var buttonCodes = try {
preferences.getStringSet(key, mutableSetOf<String>())
} catch (e: ClassCastException) {
val prefInt = preferences.getInt(key, -1);
val migratedSet = if (prefInt != -1) {
mutableSetOf(prefInt.toString())
} else {
mutableSetOf<String>()
}
migratedSet
}
if (buttonCodes == null) buttonCodes = mutableSetOf<String>()
return buttonCodes.mapNotNull { it.toIntOrNull() }.toMutableSet()
}
private fun getInputButtonKey(keyId: Int): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${keyId}"
/** 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.
*/
fun getInputAxisKey(axis: Int): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${axis}"
/**
* Helper function to get the settings key for an gamepad axis button (stick or trigger).
*/
fun getInputAxisButtonKey(axis: Int): String = "${getInputAxisKey(axis)}_GuestButton"
/**
* Helper function to get the settings key for an whether a gamepad axis is inverted.
*/
fun getInputAxisInvertedKey(axis: Int): String = "${getInputAxisKey(axis)}_Inverted"
/**
* Helper function to get the settings key for an gamepad axis orientation.
*/
fun getInputAxisOrientationKey(axis: Int): String =
"${getInputAxisKey(axis)}_GuestOrientation"
/**
* This function translates a keyEvent into an "keyid"
* This key id is either the keyCode from the event, or
* the raw scanCode.
* Only when the keyCode itself is 0, (so it is an unknown key)
* we fall back to the raw scan code.
* This handles keys like the media-keys on google statia-controllers
* that don't have a conventional "mapping" and report as "unknown"
*/
fun translateEventToKeyId(event: KeyEvent): Int {
return if (event.keyCode == 0) {
event.scanCode
} else {
event.keyCode
}
}
}
}

View File

@ -5,26 +5,34 @@
package org.citra.citra_emu.features.settings.model.view
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.IntListSetting
import org.citra.citra_emu.features.settings.model.Settings
class MultiChoiceSetting(
setting: AbstractSetting?,
val settings: Settings,
setting: AbstractSetting<List<Int>>?,
titleId: Int,
descriptionId: Int,
val choicesId: Int,
val valuesId: Int,
val key: String? = null,
val defaultValue: List<Int>? = null,
override var isEnabled: Boolean = true
override var isEnabled: Boolean = true,
private val getValue: (()->List<Int>)? = null,
private val setValue: ((List<Int>)-> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_MULTI_CHOICE
val selectedValues: List<Int>
get() {
if (getValue != null) {
@Suppress("UNCHECKED_CAST")
return getValue.invoke()
}
if (setting == null) {
return defaultValue!!
}
try {
val setting = setting as IntListSetting
return setting.list
return settings.get(setting as IntListSetting)
}catch (_: ClassCastException) {
}
return defaultValue!!
@ -35,12 +43,14 @@ class MultiChoiceSetting(
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: List<Int>): IntListSetting {
val intSetting = setting as IntListSetting
intSetting.list = selection
return intSetting
fun setSelectedValue(selection: List<Int>) {
if (setValue != null) {
setValue(selection)
}else {
val intSetting = setting as IntListSetting
settings.set(intSetting, selection)
}
}
}

View File

@ -16,7 +16,7 @@ import org.citra.citra_emu.features.settings.model.AbstractSetting
* file.)
*/
abstract class SettingsItem(
var setting: AbstractSetting?,
var setting: AbstractSetting<*>?,
val nameId: Int,
val descriptionId: Int
) {
@ -35,6 +35,8 @@ abstract class SettingsItem(
return this.isEditable && this.isEnabled
}
companion object {
const val TYPE_HEADER = 0
const val TYPE_SWITCH = 1

View File

@ -1,62 +1,48 @@
// 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 org.citra.citra_emu.features.settings.model.AbstractIntSetting
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.AbstractShortSetting
import org.citra.citra_emu.features.settings.model.Settings
class SingleChoiceSetting(
setting: AbstractSetting?,
val settings: Settings,
setting: AbstractSetting<*>?,
titleId: Int,
descriptionId: Int,
val choicesId: Int,
val valuesId: Int,
val key: String? = null,
val defaultValue: Int? = null,
override var isEnabled: Boolean = true
override var isEnabled: Boolean = true,
private val getValue: (()->Int)? = null,
private val setValue: ((Int)-> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SINGLE_CHOICE
val selectedValue: Int
get() {
if (setting == null) {
return defaultValue!!
if (getValue != null) {
return getValue.invoke()
}
try {
val setting = setting as AbstractIntSetting
return setting.int
} catch (_: ClassCastException) {
}
try {
val setting = setting as AbstractShortSetting
return setting.short.toInt()
} catch (_: ClassCastException) {
}
return defaultValue!!
@Suppress("UNCHECKED_CAST")
val s = (setting as? AbstractSetting<Int>) ?: return defaultValue!!
return settings.get(s)
}
/**
* Write a value to the backing int. If that int was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* Write a value to the backing int .
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: Int): AbstractIntSetting {
val intSetting = setting as AbstractIntSetting
intSetting.int = selection
return intSetting
}
fun setSelectedValue(selection: Short): AbstractShortSetting {
val shortSetting = setting as AbstractShortSetting
shortSetting.short = selection
return shortSetting
fun setSelectedValue(selection: Int) {
if (setValue != null) {
setValue(selection)
}else {
@Suppress("UNCHECKED_CAST")
val backSetting = setting as AbstractSetting<Int>
settings.set(backSetting, selection)
}
}
}

View File

@ -4,15 +4,16 @@
package org.citra.citra_emu.features.settings.model.view
import org.citra.citra_emu.features.settings.model.AbstractFloatSetting
import org.citra.citra_emu.features.settings.model.AbstractIntSetting
import org.citra.citra_emu.features.settings.model.AbstractSetting
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.Settings
import org.citra.citra_emu.utils.Log
import kotlin.math.pow
import kotlin.math.roundToInt
class SliderSetting(
setting: AbstractSetting?,
val settings: Settings,
setting: AbstractSetting<*>?,
titleId: Int,
descriptionId: Int,
val min: Int,
@ -20,17 +21,28 @@ class SliderSetting(
val units: String,
val key: String? = null,
val defaultValue: Float? = null,
override var isEnabled: Boolean = true
val rounding: Int = 2,
override var isEnabled: Boolean = true,
private val getValue: (()->Float)? = null,
private val setValue: ((Float)-> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SLIDER
val selectedFloat: Float
get() {
val setting = setting ?: return defaultValue!!.toFloat()
if (getValue != null) return getValue.invoke()
val s = setting ?: return defaultValue!!
val ret = when (s.defaultValue) {
is Int -> {
@Suppress("UNCHECKED_CAST")
settings.get(s as AbstractSetting<Int>).toFloat()
}
is Float -> {
@Suppress("UNCHECKED_CAST")
settings.get(s as AbstractSetting<Float>)
}
val ret = when (setting) {
is AbstractIntSetting -> setting.int.toFloat()
is FloatSetting -> setting.float
is ScaledFloatSetting -> setting.float
else -> {
Log.error("[SliderSetting] Error casting setting type.")
-1f
@ -38,16 +50,32 @@ class SliderSetting(
}
return ret.coerceIn(min.toFloat(), max.toFloat())
}
/**
* Write a value to the backing int. If that int was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: Int): AbstractIntSetting {
val intSetting = setting as AbstractIntSetting
intSetting.int = selection
fun roundedFloat(value: Float): Float {
val factor = 10f.pow(rounding)
return (value * factor).roundToInt() / factor
}
val valueAsString: String
get() = setting?.let {
when (it.defaultValue) {
is Int -> {
@Suppress("UNCHECKED_CAST")
settings.get(it as AbstractSetting<Int>).toString()
}
is Float -> {
@Suppress("UNCHECKED_CAST")
roundedFloat(settings.get(it as AbstractSetting<Float>)).toString()
}
else -> ""
}
} ?: defaultValue?.toString() ?: ""
fun setSelectedValue(selection: Int): AbstractSetting<Int> {
@Suppress("UNCHECKED_CAST")
val intSetting = setting as AbstractSetting<Int>
settings.set(intSetting, selection)
return intSetting
}
@ -56,15 +84,14 @@ class SliderSetting(
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the float.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: Float): AbstractFloatSetting {
val floatSetting = setting as AbstractFloatSetting
if (floatSetting is ScaledFloatSetting) {
floatSetting.float = selection
} else {
floatSetting.float = selection
fun setSelectedValue(selection: Float) {
if (setValue != null) {
setValue(selection)
}else {
@Suppress("UNCHECKED_CAST")
val floatSetting = setting as AbstractSetting<Float>
settings.set(floatSetting, selection)
}
return floatSetting
}
}

View File

@ -1,28 +1,40 @@
// 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 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 StringInputSetting(
setting: AbstractSetting?,
val settings: Settings,
setting: AbstractSetting<String>?,
titleId: Int,
descriptionId: Int,
val defaultValue: String,
val characterLimit: Int = 0,
override var isEnabled: Boolean = true
override var isEnabled: Boolean = true,
private val getValue: (()->String)? = null,
private val setValue: ((String)-> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_STRING_INPUT
val selectedValue: String
get() = setting?.valueAsString ?: defaultValue
@Suppress("UNCHECKED_CAST")
get() {
if (getValue != null) return getValue.invoke()
setting ?: return defaultValue
return settings.get(setting as AbstractSetting<String>)
}
fun setSelectedValue(selection: String): AbstractStringSetting {
val stringSetting = setting as AbstractStringSetting
stringSetting.string = selection
return stringSetting
fun setSelectedValue(selection: String) {
if (setValue != null) {
setValue.invoke(selection)
}else {
@Suppress("UNCHECKED_CAST")
val stringSetting = setting as AbstractSetting<String>
settings.set(stringSetting, selection)
}
}
}

View File

@ -1,22 +1,24 @@
// 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 org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.AbstractShortSetting
import org.citra.citra_emu.features.settings.model.AbstractStringSetting
import org.citra.citra_emu.features.settings.model.Settings
class StringSingleChoiceSetting(
setting: AbstractSetting?,
val settings: Settings,
setting: AbstractSetting<String>?,
titleId: Int,
descriptionId: Int,
val choices: Array<String>,
val values: Array<String>?,
val key: String? = null,
private val defaultValue: String? = null,
override var isEnabled: Boolean = true
override var isEnabled: Boolean = true,
private val getValue: (()->String)? = null,
private val setValue: ((String)-> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_STRING_SINGLE_CHOICE
@ -31,22 +33,12 @@ class StringSingleChoiceSetting(
val selectedValue: String
get() {
if (getValue != null) return getValue.invoke()
if (setting == null) {
return defaultValue!!
}
try {
val setting = setting as AbstractStringSetting
return setting.string
} catch (_: ClassCastException) {
}
try {
val setting = setting as AbstractShortSetting
return setting.short.toString()
} catch (_: ClassCastException) {
}
return defaultValue!!
@Suppress("UNCHECKED_CAST")
return settings.get(setting as AbstractSetting<String>)
}
val selectValueIndex: Int
get() {
@ -64,17 +56,14 @@ class StringSingleChoiceSetting(
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: String): AbstractStringSetting {
val stringSetting = setting as AbstractStringSetting
stringSetting.string = selection
return stringSetting
}
fun setSelectedValue(selection: Short): AbstractShortSetting {
val shortSetting = setting as AbstractShortSetting
shortSetting.short = selection
return shortSetting
fun setSelectedValue(selection: String) {
if (setValue != null) {
setValue(selection)
}else {
@Suppress("UNCHECKED_CAST")
val stringSetting = setting as AbstractSetting<String>
settings.set(stringSetting, selection)
}
}
}

View File

@ -4,38 +4,45 @@
package org.citra.citra_emu.features.settings.model.view
import org.citra.citra_emu.features.settings.model.AbstractBooleanSetting
import org.citra.citra_emu.features.settings.model.AbstractIntSetting
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.Settings
class SwitchSetting(
setting: AbstractBooleanSetting,
val settings: Settings,
setting: AbstractSetting<Boolean>?,
titleId: Int,
descriptionId: Int,
val key: String? = null,
val defaultValue: Boolean = false,
override var isEnabled: Boolean = true
override var isEnabled: Boolean = true,
private val getValue: (() -> Boolean)? = null,
private val setValue: ((Boolean) -> Unit)? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SWITCH
val isChecked: Boolean
get() {
if (getValue != null) return getValue.invoke()
if (setting == null) {
return defaultValue
}
val setting = setting as AbstractBooleanSetting
return setting.boolean
@Suppress("UNCHECKED_CAST")
val setting = setting as AbstractSetting<Boolean>
return settings.get(setting)
}
/**
* Write a value to the backing boolean.
*
* @param checked Pretty self explanatory.
* @return the existing setting with the new value applied.
*/
fun setChecked(checked: Boolean): AbstractBooleanSetting {
val setting = setting as AbstractBooleanSetting
setting.boolean = checked
return setting
fun setChecked(checked: Boolean) {
if (setValue != null) {
setValue(checked)
}else {
@Suppress("UNCHECKED_CAST")
val setting = setting as AbstractSetting<Boolean>
settings.set(setting, checked)
}
}
}

View File

@ -7,7 +7,6 @@ package org.citra.citra_emu.features.settings.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
@ -20,20 +19,14 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.preference.PreferenceManager
import com.google.android.material.color.MaterialColors
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.databinding.ActivitySettingsBinding
import java.io.IOException
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.FloatSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.ScaledFloatSetting
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.model.StringSetting
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.utils.SystemSaveGame
import org.citra.citra_emu.utils.DirectoryInitialization
@ -42,12 +35,13 @@ import org.citra.citra_emu.utils.RefreshRateUtil
import org.citra.citra_emu.utils.ThemeUtil
class SettingsActivity : AppCompatActivity(), SettingsActivityView {
private val presenter = SettingsActivityPresenter(this)
private lateinit var binding: ActivitySettingsBinding
val settingsViewModel: SettingsViewModel by viewModels()
private val settingsViewModel: SettingsViewModel by viewModels()
private val presenter by lazy { SettingsActivityPresenter(this, settingsViewModel) }
// the activity will work with the fresh Settings() object created and stored in the viewmodel
override val settings: Settings get() = settingsViewModel.settings
override fun onCreate(savedInstanceState: Bundle?) {
@ -63,9 +57,9 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
WindowCompat.setDecorFitsSystemWindows(window, false)
val launcher = intent
val gameID = launcher.getStringExtra(ARG_GAME_ID)
val menuTag = launcher.getStringExtra(ARG_MENU_TAG)
presenter.onCreate(savedInstanceState, menuTag!!, gameID!!)
val gameID = launcher.getStringExtra(ARG_GAME_ID) ?: ""
val menuTag = launcher.getStringExtra(ARG_MENU_TAG) ?: ""
presenter.onCreate(savedInstanceState, menuTag, gameID)
// Show "Back" button in the action bar for navigation
setSupportActionBar(binding.toolbarSettings)
@ -204,20 +198,6 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
// Prevents saving to a non-existent settings file
presenter.onSettingsReset()
val controllerKeys = Settings.buttonKeys + Settings.circlePadKeys + Settings.cStickKeys +
Settings.dPadAxisKeys + Settings.dPadButtonKeys + Settings.triggerKeys
val editor =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext).edit()
controllerKeys.forEach { editor.remove(it) }
editor.apply()
// Reset the static memory representation of each setting
BooleanSetting.clear()
FloatSetting.clear()
ScaledFloatSetting.clear()
IntSetting.clear()
StringSetting.clear()
// Delete settings file because the user may have changed values that do not exist in the UI
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
if (!settingsFile.delete()) {

View File

@ -12,6 +12,8 @@ import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.utils.SystemSaveGame
import org.citra.citra_emu.utils.DirectoryInitialization
import org.citra.citra_emu.utils.FileUtil
@ -19,16 +21,32 @@ import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.utils.PermissionsHandler
import org.citra.citra_emu.utils.TurboHelper
class SettingsActivityPresenter(private val activityView: SettingsActivityView) {
val settings: Settings get() = activityView.settings
class SettingsActivityPresenter(private val activityView: SettingsActivityView, private val viewModel: SettingsViewModel) {
val settings: Settings get() = viewModel.settings
private var shouldSave = false
private lateinit var menuTag: String
private lateinit var gameId: String
private var perGameInGlobalContext = false
fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) {
this.menuTag = menuTag
this.gameId = gameId
perGameInGlobalContext = gameId != "" && ! Settings.settings.isPerGame()
// sync the active settings into my local settings appropriately
// if we are editing global settings rom a game, this should just work
// to sync only the global settings into the local version
settings.gameId = gameId
settings.mergeSettings(Settings.settings)
// if we are editing per-game settings when the game is not loaded,
// we need to load the per-game settings now from the ini file
if (perGameInGlobalContext) {
SettingsFile.loadSettings(settings, gameId)
}
if (savedInstanceState != null) {
shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
}
@ -47,13 +65,6 @@ class SettingsActivityPresenter(private val activityView: SettingsActivityView)
}
private fun loadSettingsUI() {
if (!settings.isLoaded) {
if (!TextUtils.isEmpty(gameId)) {
settings.loadSettings(gameId, activityView)
} else {
settings.loadSettings(activityView)
}
}
activityView.showSettingsFragment(menuTag, false, gameId)
activityView.onSettingsFileLoaded()
}
@ -72,7 +83,8 @@ class SettingsActivityPresenter(private val activityView: SettingsActivityView)
val nomediaFileExists: Boolean
try {
dataDirTreeUri = PermissionsHandler.citraDirectory
dataDirDocument = DocumentFile.fromTreeUri(CitraApplication.appContext, dataDirTreeUri)!!
dataDirDocument =
DocumentFile.fromTreeUri(CitraApplication.appContext, dataDirTreeUri)!!
nomediaFileDocument = dataDirDocument.findFile(".nomedia")
nomediaFileExists = (nomediaFileDocument != null)
} catch (e: Exception) {
@ -80,7 +92,7 @@ class SettingsActivityPresenter(private val activityView: SettingsActivityView)
return
}
if (BooleanSetting.ANDROID_HIDE_IMAGES.boolean) {
if (settings.get(BooleanSetting.ANDROID_HIDE_IMAGES)) {
if (!nomediaFileExists) {
Log.info("[SettingsActivity]: Attempting to create .nomedia in user data directory")
FileUtil.createFile(dataDirTreeUri.toString(), ".nomedia")
@ -94,14 +106,18 @@ class SettingsActivityPresenter(private val activityView: SettingsActivityView)
fun onStop(finishing: Boolean) {
if (finishing && shouldSave) {
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
settings.saveSettings(activityView)
//added to ensure that layout changes take effect as soon as settings window closes
if (settings.isPerGame()) {
SettingsFile.saveCustomFile(settings,activityView)
}else{
SettingsFile.saveGlobalFile(settings,activityView)
}
// merge the edited settings back into the active settings
Settings.settings.mergeSettings(settings)
NativeLibrary.reloadSettings()
NativeLibrary.updateFramebuffer(NativeLibrary.isPortraitMode)
updateAndroidImageVisibility()
TurboHelper.reloadTurbo(false) // TODO: Can this go somewhere else? -OS
TurboHelper.reloadTurbo(false, settings) // TODO: Can this go somewhere else? -OS
}
NativeLibrary.reloadSettings()
}
fun onSettingChanged() {

View File

@ -35,15 +35,8 @@ import org.citra.citra_emu.databinding.DialogSoftwareKeyboardBinding
import org.citra.citra_emu.databinding.ListItemSettingBinding
import org.citra.citra_emu.databinding.ListItemSettingSwitchBinding
import org.citra.citra_emu.databinding.ListItemSettingsHeaderBinding
import org.citra.citra_emu.features.settings.model.AbstractBooleanSetting
import org.citra.citra_emu.features.settings.model.AbstractFloatSetting
import org.citra.citra_emu.features.settings.model.AbstractIntSetting
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.FloatSetting
import org.citra.citra_emu.features.settings.model.IntListSetting
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.view.DateTimeSetting
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.features.settings.model.view.SettingsItem
@ -74,11 +67,12 @@ import java.text.SimpleDateFormat
import kotlin.math.roundToInt
class SettingsAdapter(
private val fragmentView: SettingsFragmentView,
public val context: Context
) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener,
val fragmentView: SettingsFragmentView,
val context: Context
) : RecyclerView.Adapter<SettingViewHolder<SettingsItem>?>(), DialogInterface.OnClickListener,
DialogInterface.OnMultiChoiceClickListener {
private var settings: ArrayList<SettingsItem>? = null
var isPerGame: Boolean = false
private var clickedItem: SettingsItem? = null
private var clickedPosition: Int
private var dialog: AlertDialog? = null
@ -94,7 +88,7 @@ class SettingsAdapter(
clickedPosition = -1
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder<*> {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
SettingsItem.TYPE_HEADER -> {
@ -144,7 +138,7 @@ class SettingsAdapter(
}
}
override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
override fun onBindViewHolder(holder: SettingViewHolder<SettingsItem>, position: Int) {
getItem(position)?.let { holder.bind(it) }
}
@ -226,14 +220,15 @@ class SettingsAdapter(
}
fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) {
val setting = item.setChecked(checked)
fragmentView.putSetting(setting)
item.setChecked(checked)
fragmentView.onSettingChanged()
// If statement is required otherwise the app will crash on activity recreate ex. theme settings
if (fragmentView.activityView != null)
// Reload the settings list to update the UI
fragmentView.loadSettingsList()
notifyItemChanged(position)
}
private fun onSingleChoiceClick(item: SingleChoiceSetting) {
@ -253,7 +248,7 @@ class SettingsAdapter(
private fun onMultiChoiceClick(item: MultiChoiceSetting) {
clickedItem = item
val value: BooleanArray = getSelectionForMultiChoiceValue(item);
val value: BooleanArray = getSelectionForMultiChoiceValue(item)
dialog = MaterialAlertDialogBuilder(context)
.setTitle(item.nameId)
.setMultiChoiceItems(item.choicesId, value, this)
@ -273,7 +268,7 @@ class SettingsAdapter(
private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) {
clickedItem = item
dialog = context?.let {
dialog = context.let {
MaterialAlertDialogBuilder(it)
.setTitle(item.nameId)
.setSingleChoiceItems(item.choices, item.selectValueIndex, this)
@ -338,8 +333,7 @@ class SettingsAdapter(
fragmentView.onSettingChanged()
}
notifyItemChanged(clickedPosition)
val setting = item.setSelectedValue(rtcString)
fragmentView.putSetting(setting)
item.setSelectedValue(rtcString)
fragmentView.loadSettingsList()
clickedItem = null
}
@ -359,7 +353,7 @@ class SettingsAdapter(
val sliderBinding = DialogSliderBinding.inflate(inflater)
textInputLayout = sliderBinding.textInput
textSliderValue = sliderBinding.textValue
if (item.setting is FloatSetting) {
if (item.setting?.defaultValue is Float) {
textSliderValue?.let {
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
it.setText(sliderProgress.toString())
@ -376,9 +370,9 @@ class SettingsAdapter(
value = sliderProgress
textSliderValue?.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {
var textValue = s.toString().toFloatOrNull();
var textValue = s.toString().toFloatOrNull()
if (item.setting !is FloatSetting) {
textValue = textValue?.roundToInt()?.toFloat();
textValue = textValue?.roundToInt()?.toFloat()
}
if (textValue == null || textValue < valueFrom || textValue > valueTo) {
textInputLayout?.error = "Inappropriate value"
@ -393,9 +387,9 @@ class SettingsAdapter(
})
addOnChangeListener { _: Slider, value: Float, _: Boolean ->
sliderProgress = (value * 100).roundToInt().toFloat() / 100f
sliderProgress = item.roundedFloat(value)
var sliderString = sliderProgress.toString()
if (item.setting !is FloatSetting) {
if (item.setting?.defaultValue !is Float) {
sliderString = sliderProgress.roundToInt().toString()
if (textSliderValue?.text.toString() != sliderString) {
textSliderValue?.setText(sliderString)
@ -418,16 +412,12 @@ class SettingsAdapter(
.setPositiveButton(android.R.string.ok, this)
.setNegativeButton(android.R.string.cancel, defaultCancelListener)
.setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int ->
sliderBinding.slider?.value = when (item.setting) {
is ScaledFloatSetting -> {
val scaledSetting = item.setting as ScaledFloatSetting
scaledSetting.defaultValue * scaledSetting.scale
}
is FloatSetting -> (item.setting as FloatSetting).defaultValue
else -> item.defaultValue ?: 0f
sliderBinding.slider.value = when (item.setting?.defaultValue) {
is Float -> item.setting!!.defaultValue as Float
is Int -> (item.setting!!.defaultValue as Int).toFloat()
else -> 0f
}
onClick(dialog, which)
onClick(dialog, which)
}
.show()
}
@ -478,26 +468,9 @@ class SettingsAdapter(
is SingleChoiceSetting -> {
val scSetting = clickedItem as? SingleChoiceSetting
scSetting?.let {
val setting = when (it.setting) {
is AbstractIntSetting -> {
val value = getValueForSingleChoiceSelection(it, which)
if (it.selectedValue != value) {
fragmentView?.onSettingChanged()
}
it.setSelectedValue(value)
}
is AbstractShortSetting -> {
val value = getValueForSingleChoiceSelection(it, which).toShort()
if (it.selectedValue.toShort() != value) {
fragmentView?.onSettingChanged()
}
it.setSelectedValue(value)
}
else -> throw IllegalStateException("Unrecognized type used for SingleChoiceSetting!")
}
fragmentView?.putSetting(setting)
val value = getValueForSingleChoiceSelection(it, which)
if (it.selectedValue != value) fragmentView.onSettingChanged()
it.setSelectedValue(value)
fragmentView.loadSettingsList()
closeDialog()
}
@ -506,22 +479,9 @@ class SettingsAdapter(
is StringSingleChoiceSetting -> {
val scSetting = clickedItem as? StringSingleChoiceSetting
scSetting?.let {
val setting = when (it.setting) {
is AbstractStringSetting -> {
val value = it.getValueAt(which)
if (it.selectedValue != value) fragmentView?.onSettingChanged()
it.setSelectedValue(value ?: "")
}
is AbstractShortSetting -> {
if (it.selectValueIndex != which) fragmentView?.onSettingChanged()
it.setSelectedValue(it.getValueAt(which)?.toShort() ?: 1)
}
else -> throw IllegalStateException("Unrecognized type used for StringSingleChoiceSetting!")
}
fragmentView?.putSetting(setting)
val value = it.getValueAt(which) ?: ""
if (it.selectedValue != value) fragmentView.onSettingChanged()
it.setSelectedValue(value)
fragmentView.loadSettingsList()
closeDialog()
}
@ -530,21 +490,11 @@ class SettingsAdapter(
is SliderSetting -> {
val sliderSetting = clickedItem as? SliderSetting
sliderSetting?.let {
val sliderval = (it.selectedFloat * 100).roundToInt().toFloat() / 100
if (sliderval != sliderProgress) {
fragmentView?.onSettingChanged()
}
when (it.setting) {
is AbstractIntSetting -> {
val value = sliderProgress.roundToInt()
val setting = it.setSelectedValue(value)
fragmentView?.putSetting(setting)
}
else -> {
val setting = it.setSelectedValue(sliderProgress)
fragmentView?.putSetting(setting)
}
val sliderval = it.roundedFloat(sliderProgress)
if (sliderval != it.selectedFloat) fragmentView.onSettingChanged()
when {
it.setting?.defaultValue is Int -> it.setSelectedValue(sliderProgress.roundToInt())
else -> it.setSelectedValue(sliderProgress)
}
fragmentView.loadSettingsList()
closeDialog()
@ -555,10 +505,9 @@ class SettingsAdapter(
val inputSetting = clickedItem as? StringInputSetting
inputSetting?.let {
if (it.selectedValue != textInputValue) {
fragmentView?.onSettingChanged()
fragmentView.onSettingChanged()
}
val setting = it.setSelectedValue(textInputValue ?: "")
fragmentView?.putSetting(setting)
it.setSelectedValue(textInputValue)
fragmentView.loadSettingsList()
closeDialog()
}
@ -575,36 +524,19 @@ class SettingsAdapter(
mcsetting?.let {
val value = getValueForMultiChoiceSelection(it, which)
if (it.selectedValues.contains(value) != isChecked) {
val setting = it.setSelectedValue((if (isChecked) it.selectedValues + value else it.selectedValues - value).sorted())
fragmentView?.putSetting(setting)
fragmentView?.onSettingChanged()
it.setSelectedValue((if (isChecked) it.selectedValues + value else it.selectedValues - value).sorted())
fragmentView.onSettingChanged()
}
fragmentView.loadSettingsList()
}
}
fun onLongClick(setting: AbstractSetting, position: Int): Boolean {
fun onLongClick(setting: AbstractSetting<*>, position: Int): Boolean {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.reset_setting_confirmation)
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
when (setting) {
is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
is AbstractFloatSetting -> {
if (setting is ScaledFloatSetting) {
setting.float = setting.defaultValue * setting.scale
} else {
setting.float = setting.defaultValue as Float
}
}
is AbstractIntSetting -> setting.int = setting.defaultValue as Int
is AbstractStringSetting -> setting.string = setting.defaultValue as String
is AbstractShortSetting -> setting.short = setting.defaultValue as Short
}
notifyItemChanged(position)
fragmentView.onSettingChanged()
fragmentView.loadSettingsList()
resetSettingToDefault(setting, position)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
@ -612,6 +544,23 @@ class SettingsAdapter(
return true
}
fun <T> resetSettingToDefault(setting: AbstractSetting<T>, position: Int) {
val settings = fragmentView.activityView?.settings ?: return
settings.set(setting,setting.defaultValue)
notifyItemChanged(position)
fragmentView.onSettingChanged()
fragmentView.loadSettingsList()
}
fun <T> resetSettingToGlobal(setting: AbstractSetting<T>, position: Int) {
val settings = fragmentView.activityView?.settings ?: return
settings.clearOverride(setting)
notifyItemChanged(position)
fragmentView.onSettingChanged()
fragmentView.loadSettingsList()
}
fun onInputBindingLongClick(setting: InputBindingSetting, position: Int): Boolean {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.reset_setting_confirmation)
@ -652,8 +601,9 @@ class SettingsAdapter(
}
fun onLongClickAutoMap(): Boolean {
val settings = fragmentView.activityView?.settings ?: return false
showConfirmationDialog(R.string.controller_clear_all, R.string.controller_clear_all_confirm) {
InputBindingSetting.clearAllBindings()
settings.inputMappingManager.clear()
fragmentView.loadSettingsList()
fragmentView.onSettingChanged()
}
@ -732,18 +682,18 @@ class SettingsAdapter(
}
private fun getSelectionForMultiChoiceValue(item: MultiChoiceSetting): BooleanArray {
val value = item.selectedValues;
val valuesId = item.valuesId;
val value = item.selectedValues
val valuesId = item.valuesId
if (valuesId > 0) {
val valuesArray = context.resources.getIntArray(valuesId);
val valuesArray = context.resources.getIntArray(valuesId)
val res = BooleanArray(valuesArray.size){false}
for (index in valuesArray.indices) {
if (value.contains(valuesArray[index])) {
res[index] = true;
res[index] = true
}
}
return res;
return res
}
return BooleanArray(1){false};
return BooleanArray(1){false}
}
}

View File

@ -1,4 +1,4 @@
// 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.
@ -13,16 +13,19 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.citra.citra_emu.databinding.FragmentSettingsBinding
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.model.view.SettingsItem
import kotlin.getValue
class SettingsFragment : Fragment(), SettingsFragmentView {
override var activityView: SettingsActivityView? = null
private val fragmentPresenter = SettingsFragmentPresenter(this)
private val settingsViewModel: SettingsViewModel by activityViewModels()
private val fragmentPresenter by lazy { SettingsFragmentPresenter(this) }
private var settingsAdapter: SettingsAdapter? = null
private var _binding: FragmentSettingsBinding? = null
@ -37,7 +40,7 @@ class SettingsFragment : Fragment(), SettingsFragmentView {
super.onCreate(savedInstanceState)
val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG)
val gameId = requireArguments().getString(ARGUMENT_GAME_ID)
fragmentPresenter.onCreate(menuTag!!, gameId!!)
fragmentPresenter.onCreate(menuTag!!, gameId!!, settingsViewModel.settings)
}
override fun onCreateView(
@ -88,10 +91,6 @@ class SettingsFragment : Fragment(), SettingsFragmentView {
activityView!!.showToastMessage(message!!, is_long)
}
override fun putSetting(setting: AbstractSetting) {
fragmentPresenter.putSetting(setting)
}
override fun onSettingChanged() {
activityView!!.onSettingChanged()
}

View File

@ -1,10 +1,9 @@
// 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.ui
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.view.SettingsItem
/**
@ -45,13 +44,6 @@ interface SettingsFragmentView {
*/
fun showToastMessage(message: String?, is_long: Boolean)
/**
* Have the fragment add a setting to the HashMap.
*
* @param setting The (possibly previously missing) new setting.
*/
fun putSetting(setting: AbstractSetting)
/**
* Have the fragment tell the containing Activity that a setting was modified.
*/

View File

@ -18,12 +18,11 @@ import org.citra.citra_emu.features.settings.ui.SettingsAdapter
import java.text.SimpleDateFormat
class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: DateTimeSetting
SettingViewHolder<DateTimeSetting>(binding.root, adapter) {
override lateinit var setting: DateTimeSetting
@SuppressLint("SimpleDateFormat")
override fun bind(item: SettingsItem) {
setting = item as DateTimeSetting
setting = item as? DateTimeSetting ?: return
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.visibility = View.VISIBLE
@ -56,6 +55,9 @@ class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsA
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
override fun onClick(clicked: View) {

View File

@ -1,4 +1,4 @@
// 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.
@ -10,8 +10,8 @@ import org.citra.citra_emu.features.settings.model.view.SettingsItem
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
SettingViewHolder<SettingsItem>(binding.root, adapter) {
override var setting = null
init {
itemView.setOnClickListener(null)
}

View File

@ -13,14 +13,12 @@ import org.citra.citra_emu.features.settings.model.view.SettingsItem
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class InputBindingSettingViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: InputBindingSetting
SettingViewHolder<InputBindingSetting>(binding.root, adapter) {
override lateinit var setting: InputBindingSetting
override fun bind(item: SettingsItem) {
val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
setting = item as InputBindingSetting
binding.textSettingName.setText(item.nameId)
val uiString = preferences.getString(setting.abstractSetting.key, "")!!
val uiString = setting.value
if (uiString.isNotEmpty()) {
binding.textSettingDescription.visibility = View.GONE
binding.textSettingValue.visibility = View.VISIBLE
@ -39,6 +37,8 @@ class InputBindingSettingViewHolder(val binding: ListItemSettingBinding, adapter
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
override fun onClick(clicked: View) {

View File

@ -11,9 +11,8 @@ import org.citra.citra_emu.features.settings.model.view.MultiChoiceSetting
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class MultiChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SettingsItem
SettingViewHolder<SettingsItem>(binding.root, adapter) {
override lateinit var setting: SettingsItem
override fun bind(item: SettingsItem) {
setting = item
binding.textSettingName.setText(item.nameId)
@ -35,6 +34,8 @@ class MultiChoiceViewHolder(val binding: ListItemSettingBinding, adapter: Settin
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
private fun getTextSetting(): String {

View File

@ -14,8 +14,8 @@ import org.citra.citra_emu.features.settings.model.view.SettingsItem
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: RunnableSetting
SettingViewHolder<RunnableSetting>(binding.root, adapter) {
override lateinit var setting: RunnableSetting
override fun bind(item: SettingsItem) {
setting = item as RunnableSetting

View File

@ -9,14 +9,17 @@ import androidx.recyclerview.widget.RecyclerView
import org.citra.citra_emu.features.settings.model.view.SettingsItem
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
abstract class SettingViewHolder<out T: SettingsItem>(itemView: View, protected val adapter: SettingsAdapter) :
RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
init {
itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this)
}
/**
* The SettingsItem we are holding
*/
abstract val setting: T?
/**
* Called by the adapter to set this ViewHolder's child views to display the list item
* it must now represent.
@ -34,4 +37,22 @@ abstract class SettingViewHolder(itemView: View, protected val adapter: Settings
abstract override fun onClick(clicked: View)
abstract override fun onLongClick(clicked: View): Boolean
fun showGlobalButtonIfNeeded(buttonUseGlobal: View, position: Int) {
setting ?: return
// Show "Revert to global" button in Custom Settings if applicable.
val settings = adapter.fragmentView.activityView?.settings
val showGlobal = settings?.isPerGame() == true
&& setting?.setting != null
&& settings.hasOverride(setting!!.setting!!)
buttonUseGlobal.visibility = if (showGlobal) View.VISIBLE else View.GONE
if (showGlobal) {
buttonUseGlobal.setOnClickListener {
setting?.setting?.let { descriptor ->
adapter.resetSettingToGlobal(descriptor, bindingAdapterPosition)
}
}
}
}
}

View File

@ -12,8 +12,8 @@ import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSettin
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SettingsItem
SettingViewHolder<SettingsItem>(binding.root, adapter) {
override lateinit var setting: SettingsItem
override fun bind(item: SettingsItem) {
setting = item
@ -36,6 +36,8 @@ class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: Setti
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
private fun getTextSetting(): String {

View File

@ -6,17 +6,14 @@ package org.citra.citra_emu.features.settings.ui.viewholder
import android.view.View
import org.citra.citra_emu.databinding.ListItemSettingBinding
import org.citra.citra_emu.features.settings.model.AbstractFloatSetting
import org.citra.citra_emu.features.settings.model.AbstractIntSetting
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.view.SettingsItem
import org.citra.citra_emu.features.settings.model.view.SliderSetting
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SliderSetting
SettingViewHolder<SliderSetting>(binding.root, adapter) {
override lateinit var setting: SliderSetting
override fun bind(item: SettingsItem) {
setting = item as SliderSetting
@ -28,12 +25,7 @@ class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAda
binding.textSettingDescription.visibility = View.GONE
}
binding.textSettingValue.visibility = View.VISIBLE
binding.textSettingValue.text = when (setting.setting) {
is ScaledFloatSetting ->
"${(setting.setting as ScaledFloatSetting).float.toInt()}${setting.units}"
is FloatSetting -> "${(setting.setting as AbstractFloatSetting).float}${setting.units}"
else -> "${(setting.setting as AbstractIntSetting).int}${setting.units}"
}
binding.textSettingValue.text = "${setting.valueAsString}${setting.units}"
if (setting.isActive) {
binding.textSettingName.alpha = 1f
@ -44,6 +36,8 @@ class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAda
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
override fun onClick(clicked: View) {

View File

@ -11,11 +11,10 @@ import org.citra.citra_emu.features.settings.model.view.StringInputSetting
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SettingsItem
SettingViewHolder<StringInputSetting>(binding.root, adapter) {
override lateinit var setting: StringInputSetting
override fun bind(item: SettingsItem) {
setting = item
setting = item as StringInputSetting
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.visibility = View.VISIBLE
@ -24,7 +23,7 @@ class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: Settin
binding.textSettingDescription.visibility = View.GONE
}
binding.textSettingValue.visibility = View.VISIBLE
binding.textSettingValue.text = setting.setting?.valueAsString
binding.textSettingValue.text = setting.selectedValue
if (setting.isActive) {
binding.textSettingName.alpha = 1f
@ -35,6 +34,8 @@ class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: Settin
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
override fun onClick(clicked: View) {
@ -42,7 +43,7 @@ class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: Settin
adapter.onClickDisabledSetting(!setting.isEditable)
return
}
adapter.onStringInputClick((setting as StringInputSetting), bindingAdapterPosition)
adapter.onStringInputClick(setting, bindingAdapterPosition)
}
override fun onLongClick(clicked: View): Boolean {

View File

@ -12,11 +12,11 @@ import org.citra.citra_emu.features.settings.model.view.SubmenuSetting
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var item: SubmenuSetting
SettingViewHolder<SubmenuSetting>(binding.root, adapter) {
override lateinit var setting: SubmenuSetting
override fun bind(item: SettingsItem) {
this.item = item as SubmenuSetting
setting = item as SubmenuSetting
if (item.iconId == 0) {
binding.icon.visibility = View.GONE
} else {
@ -40,7 +40,7 @@ class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAd
}
override fun onClick(clicked: View) {
adapter.onSubmenuClick(item)
adapter.onSubmenuClick(setting)
}
override fun onLongClick(clicked: View): Boolean {

View File

@ -12,9 +12,9 @@ import org.citra.citra_emu.features.settings.model.view.SwitchSetting
import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
SettingViewHolder<SwitchSetting>(binding.root, adapter) {
private lateinit var setting: SwitchSetting
override lateinit var setting: SwitchSetting
override fun bind(item: SettingsItem) {
setting = item as SwitchSetting
@ -38,6 +38,8 @@ class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter
val textAlpha = if (setting.isActive) 1f else 0.5f
binding.textSettingName.alpha = textAlpha
binding.textSettingDescription.alpha = textAlpha
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
override fun onClick(clicked: View) {

View File

@ -12,14 +12,12 @@ import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.FloatSetting
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.IntListSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.ScaledFloatSetting
import org.citra.citra_emu.features.settings.model.SettingSection
import org.citra.citra_emu.features.settings.model.Settings.SettingsSectionMap
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.StringSetting
import org.citra.citra_emu.features.settings.ui.SettingsActivityView
import org.citra.citra_emu.utils.BiMap
import org.citra.citra_emu.utils.DirectoryInitialization.userDirectory
import org.citra.citra_emu.utils.Log
import org.ini4j.Wini
@ -27,7 +25,6 @@ import java.io.BufferedReader
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStreamReader
import java.util.TreeMap
/**
@ -36,40 +33,47 @@ import java.util.TreeMap
object SettingsFile {
const val FILE_NAME_CONFIG = "config"
private var sectionsMap = BiMap<String?, String?>()
private val allSettings: List<AbstractSetting<*>> by lazy {
BooleanSetting.values().toList() +
IntSetting.values().toList() +
FloatSetting.values().toList() +
StringSetting.values().toList() +
IntListSetting.values().toList() +
InputMappingSetting.values().toList()
}
private fun findSettingByKey(key: String): AbstractSetting<*>? =
allSettings.firstOrNull { it.key == key }
/**
* Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves
* effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
* failed.
* Reads a given .ini file from disk and updates a instance of the Settings class appropriately
*
* @param ini The ini file to load the settings from
* @param settings The Settings instance to edit
* @param isCustomGame
* @param view The current view.
* @return An Observable that emits a HashMap of the file's contents, then completes.
*/
fun readFile(
ini: DocumentFile,
settings: Settings,
isCustomGame: Boolean,
view: SettingsActivityView?
): HashMap<String, SettingSection?> {
val sections: HashMap<String, SettingSection?> = SettingsSectionMap()
) {
var reader: BufferedReader? = null
try {
val context: Context = CitraApplication.appContext
val inputStream = context.contentResolver.openInputStream(ini.uri)
reader = BufferedReader(InputStreamReader(inputStream))
var current: SettingSection? = null
var currentSection: String? = null
var line: String?
while (reader.readLine().also { line = it } != null) {
if (line!!.startsWith("[") && line!!.endsWith("]")) {
current = sectionFromLine(line!!, isCustomGame)
sections[current.name] = current
} else if (current != null) {
val setting = settingFromLine(line!!)
if (setting != null) {
current.putSetting(setting)
}
if (line!!.startsWith("[") && line.endsWith("]")) {
currentSection = line.substring(1, line.length-1)
} else if (currentSection != null) {
val pair = parseLineToKeyValuePair(line) ?: continue
val (key, rawValue) = pair
val descriptor = findSettingByKey(key) ?: continue
loadSettingInto(settings, descriptor, rawValue, isCustomGame)
}
}
} catch (e: FileNotFoundException) {
@ -87,102 +91,154 @@ object SettingsFile {
}
}
}
return sections
}
fun readFile(fileName: String, view: SettingsActivityView?): HashMap<String, SettingSection?> {
return readFile(getSettingsFile(fileName), false, view)
}
fun readFile(fileName: String): HashMap<String, SettingSection?> = readFile(fileName, null)
/**
* Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves
* effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
* failed.
*
* @param gameId the id of the game to load it's settings.
* @param view The current view.
* Load global settings from the config file into the settings instance
*/
fun readCustomGameSettings(
gameId: String,
view: SettingsActivityView?
): HashMap<String, SettingSection?> {
return readFile(getCustomGameSettingsFile(gameId), true, view)
fun loadSettings(settings: Settings, view: SettingsActivityView? = null) {
readFile(getSettingsFile(FILE_NAME_CONFIG),settings,false,view)
settings.inputMappingManager.rebuild(settings)
}
/**
* Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error
* Load global settings AND custom settings into the settings instance, sets gameId
*/
fun loadSettings(settings: Settings, gameId: String, view: SettingsActivityView? = null) {
settings.gameId = gameId
loadSettings(settings, view)
val file = findCustomGameSettingsFile(gameId) ?: return
readFile(file, settings, true, view)
settings.inputMappingManager.rebuild(settings)
}
/**
* Uses the settings object to parse the raw string and store it in the correct map
*/
@Suppress("UNCHECKED_CAST")
private fun <T> loadSettingInto(
settings: Settings,
setting: AbstractSetting<T>,
rawValue: String,
isCustomGame: Boolean
) {
val value = setting.valueFromString(rawValue) ?: return
if (isCustomGame) {
settings.setOverride(setting, value)
} else {
settings.setGlobal(setting, value)
}
}
/**
* Saves a the global settings from a Settings instance
* to the global .ini file on disk. If unsuccessful, outputs an error
* telling why it failed.
*
* @param fileName The target filename without a path or extension.
* @param sections The HashMap containing the Settings we want to serialize.
* @param settings The Settings instance we are saving
* @param view The current view.
*/
fun saveFile(
fileName: String,
sections: TreeMap<String, SettingSection?>,
view: SettingsActivityView
fun saveGlobalFile(
settings: Settings,
view: SettingsActivityView? = null
) {
val ini = getSettingsFile(fileName)
val ini = getSettingsFile(FILE_NAME_CONFIG)
try {
val context: Context = CitraApplication.appContext
val inputStream = context.contentResolver.openInputStream(ini.uri)
val writer = Wini(inputStream)
val keySet: Set<String> = sections.keys
for (key in keySet) {
val section = sections[key]
writeSection(writer, section!!)
}
inputStream!!.close()
for (setting in allSettings) {
val value = settings.getGlobal(setting) ?: continue
writeSettingToWini(writer, setting, value)
}
val outputStream = context.contentResolver.openOutputStream(ini.uri, "wt")
writer.store(outputStream)
outputStream!!.flush()
outputStream.close()
} catch (e: Exception) {
Log.error("[SettingsFile] File not found: $fileName.ini: ${e.message}")
view.showToastMessage(
Log.error("[SettingsFile] File not found: $FILE_NAME_CONFIG.ini: ${e.message}")
view?.showToastMessage(
CitraApplication.appContext
.getString(R.string.error_saving, fileName, e.message), false
.getString(R.string.error_saving, FILE_NAME_CONFIG, e.message), false
)
}
}
fun saveFile(
fileName: String,
setting: AbstractSetting
/**
* Save the per-game overrides to a per-game config file
*/
fun saveCustomFile(
settings: Settings,
view: SettingsActivityView? = null
) {
val ini = getSettingsFile(fileName)
if (!settings.isPerGame()) return
val ini = getOrCreateCustomGameSettingsFile(settings.gameId!!)
try {
val context: Context = CitraApplication.appContext
val writer = Wini()
val overrides = settings.getAllOverrides()
for (descriptor in allSettings) {
val value = overrides[descriptor.key] ?: continue
writeSettingToWini(writer, descriptor, value)
}
val outputStream = context.contentResolver.openOutputStream(ini.uri, "wt")
writer.store(outputStream)
outputStream?.flush()
outputStream?.close()
} catch (e: Exception) {
Log.error("[SettingsFile] Error saving custom file for ${settings.gameId}: ${e.message}")
view?.onSettingsFileNotFound()
}
}
fun <T> saveSetting(setting: AbstractSetting<T>, settings: Settings) {
if (settings.hasOverride(setting)) {
// Currently a per-game setting, keep it that way
val ini = getOrCreateCustomGameSettingsFile(settings.gameId!!)
writeSingleSettingToFile(ini, setting, settings.get(setting))
} else {
// Currently global, save to global file
val ini = getSettingsFile(FILE_NAME_CONFIG)
writeSingleSettingToFile(ini, setting, settings.getGlobal(setting))
}
}
private fun <T> writeSingleSettingToFile(ini: DocumentFile, setting: AbstractSetting<T>, value: T) {
try {
val context = CitraApplication.appContext
val inputStream = context.contentResolver.openInputStream(ini.uri)
val writer = Wini(inputStream)
writer.put(setting.section, setting.key, setting.valueAsString)
inputStream!!.close()
val writer = if (inputStream != null) Wini(inputStream) else Wini()
inputStream?.close()
writeSettingToWini(writer, setting, value as Any)
val outputStream = context.contentResolver.openOutputStream(ini.uri, "wt")
writer.store(outputStream)
outputStream!!.flush()
outputStream.close()
} catch (e: Exception) {
Log.error("[SettingsFile] File not found: $fileName.ini: ${e.message}")
Log.error("[SettingsFile] Error saving setting ${setting.key}: ${e.message}")
}
}
@Suppress("UNCHECKED_CAST")
private fun <T> writeSettingToWini(writer: Wini, descriptor: AbstractSetting<T>, value: Any) {
val typedValue = value as T
writer.put(descriptor.section, descriptor.key, descriptor.valueToString(typedValue))
}
private fun mapSectionNameFromIni(generalSectionName: String): String? {
return if (sectionsMap.getForward(generalSectionName) != null) {
sectionsMap.getForward(generalSectionName)
} else {
generalSectionName
}
private fun parseLineToKeyValuePair(line: String): Pair<String, String>? {
val splitLine = line.split("=".toRegex(), limit = 2)
if (splitLine.size != 2) return null
val key = splitLine[0].trim()
val value = splitLine[1].trim()
if (value.isEmpty()) return null
return Pair(key, value)
}
private fun mapSectionNameToIni(generalSectionName: String): String {
return if (sectionsMap.getBackward(generalSectionName) != null) {
sectionsMap.getBackward(generalSectionName).toString()
} else {
generalSectionName
}
}
fun getSettingsFile(fileName: String): DocumentFile {
val root = DocumentFile.fromTreeUri(CitraApplication.appContext, Uri.parse(userDirectory))
@ -190,96 +246,20 @@ object SettingsFile {
return configDirectory!!.findFile("$fileName.ini")!!
}
private fun getCustomGameSettingsFile(gameId: String): DocumentFile {
fun customExists(gameId: String): Boolean = findCustomGameSettingsFile(gameId) != null
private fun findCustomGameSettingsFile(gameId: String): DocumentFile? {
val root = DocumentFile.fromTreeUri(CitraApplication.appContext, Uri.parse(userDirectory))
val configDirectory = root!!.findFile("GameSettings")
return configDirectory!!.findFile("$gameId.ini")!!
val configDir = root?.findFile("config") ?: return null
val customDir = configDir.findFile("custom") ?: return null
return customDir.findFile("$gameId.ini")
}
private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection {
var sectionName: String = line.substring(1, line.length - 1)
if (isCustomGame) {
sectionName = mapSectionNameToIni(sectionName)
}
return SettingSection(sectionName)
}
/**
* For a line of text, determines what type of data is being represented, and returns
* a Setting object containing this data.
*
* @param line The line of text being parsed.
* @return A typed Setting containing the key/value contained in the line.
*/
private fun settingFromLine(line: String): AbstractSetting? {
val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (splitLine.size != 2) {
return null
}
val key = splitLine[0].trim { it <= ' ' }
val value = splitLine[1].trim { it <= ' ' }
if (value.isEmpty()) {
return null
}
val booleanSetting = BooleanSetting.from(key)
if (booleanSetting != null) {
booleanSetting.boolean = value.toBoolean()
return booleanSetting
}
val intSetting = IntSetting.from(key)
if (intSetting != null) {
try {
intSetting.int = value.toInt()
} catch (e: NumberFormatException) {
intSetting.int = if (value.toBoolean()) 1 else 0
}
return intSetting
}
val scaledFloatSetting = ScaledFloatSetting.from(key)
if (scaledFloatSetting != null) {
scaledFloatSetting.float = value.toFloat() * scaledFloatSetting.scale
return scaledFloatSetting
}
val floatSetting = FloatSetting.from(key)
if (floatSetting != null) {
floatSetting.float = value.toFloat()
return floatSetting
}
val stringSetting = StringSetting.from(key)
if (stringSetting != null) {
stringSetting.string = value
return stringSetting
}
val intListSetting = IntListSetting.from(key)
if (intListSetting != null) {
intListSetting.list = value.split(", ").map { it.toInt() }
}
return null
}
/**
* Writes the contents of a Section HashMap to disk.
*
* @param parser A Wini pointed at a file on disk.
* @param section A section containing settings to be written to the file.
*/
private fun writeSection(parser: Wini, section: SettingSection) {
// Write the section header.
val header = section.name
// Write this section's values.
val settings = section.settings
val keySet: Set<String> = settings.keys
for (key in keySet) {
val setting = settings[key]
parser.put(header, setting!!.key, setting.valueAsString)
}
private fun getOrCreateCustomGameSettingsFile(gameId: String): DocumentFile {
val root = DocumentFile.fromTreeUri(CitraApplication.appContext, Uri.parse(userDirectory))!!
val configDir = root.findFile("config") ?: root.createDirectory("config")!!
val customDir = configDir.findFile("custom") ?: configDir.createDirectory("custom")!!
return customDir.findFile("$gameId.ini")
?: customDir.createFile("*/*", "$gameId.ini")!!
}
}

View File

@ -11,12 +11,14 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
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
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.settings.model.SettingsViewModel
/**
* Captures a single button press to detect controller layout (Xbox vs Nintendo)
@ -25,7 +27,7 @@ import org.citra.citra_emu.utils.Log
class AutoMapDialogFragment : BottomSheetDialogFragment() {
private var _binding: DialogAutoMapBinding? = null
private val binding get() = _binding!!
private val settingsViewModel: SettingsViewModel by activityViewModels()
private var onComplete: (() -> Unit)? = null
override fun onCreateView(
@ -72,12 +74,11 @@ class AutoMapDialogFragment : BottomSheetDialogFragment() {
// 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)
val isJoyCon = GamepadHelper.isJoyCon(device)
if (isJoyCon) {
Log.info("[AutoMap] Detected Joy-Con - using Joy-Con mappings")
InputBindingSetting.clearAllBindings()
InputBindingSetting.applyJoyConBindings()
GamepadHelper.applyJoyConBindings(settingsViewModel.settings)
onComplete?.invoke()
dismiss()
return true
@ -106,8 +107,7 @@ class AutoMapDialogFragment : BottomSheetDialogFragment() {
val dpadName = if (useAxisDpad) "axis" else "button"
Log.info("[AutoMap] Detected $dpadName d-pad (device=${device?.name})")
InputBindingSetting.clearAllBindings()
InputBindingSetting.applyAutoMapBindings(isNintendoLayout, useAxisDpad)
GamepadHelper.applyAutoMapBindings(settingsViewModel.settings, isNintendoLayout, useAxisDpad)
onComplete?.invoke()
dismiss()

View File

@ -44,7 +44,6 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@ -70,7 +69,6 @@ import org.citra.citra_emu.display.ScreenLayout
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.model.Game
@ -78,7 +76,6 @@ import org.citra.citra_emu.utils.BuildUtil
import org.citra.citra_emu.utils.DirectoryInitialization
import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState
import org.citra.citra_emu.utils.EmulationMenuSettings
import org.citra.citra_emu.utils.FileUtil
import org.citra.citra_emu.utils.GameHelper
import org.citra.citra_emu.utils.GameIconUtils
import org.citra.citra_emu.utils.EmulationLifecycleUtil
@ -93,7 +90,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
private lateinit var emulationState: EmulationState
private var perfStatsUpdater: Runnable? = null
private lateinit var emulationActivity: EmulationActivity
private var emulationActivity: EmulationActivity? = null
private var _binding: FragmentEmulationBinding? = null
private val binding get() = _binding!!
@ -104,8 +101,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
private lateinit var screenAdjustmentUtil: ScreenAdjustmentUtil
private val emulationViewModel: EmulationViewModel by activityViewModels()
private val settingsViewModel: SettingsViewModel by viewModels()
private val settings get() = settingsViewModel.settings
private val onPause = Runnable{ togglePause() }
private val onShutdown = Runnable{ emulationState.stop() }
@ -184,7 +179,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
retainInstance = true
emulationState = EmulationState(game.path)
emulationActivity = requireActivity() as EmulationActivity
screenAdjustmentUtil = ScreenAdjustmentUtil(requireContext(), requireActivity().windowManager, settings)
screenAdjustmentUtil = ScreenAdjustmentUtil(requireContext(), requireActivity().windowManager, Settings.settings)
EmulationLifecycleUtil.addPauseResumeHook(onPause)
EmulationLifecycleUtil.addShutdownHook(onShutdown)
}
@ -211,7 +206,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
if (requireActivity().isFinishing) {
return
}
binding.surfaceInputOverlay.initializeSettings(Settings.settings)
binding.surfaceEmulation.holder.addCallback(this)
binding.doneControlConfig.setOnClickListener {
binding.doneControlConfig.visibility = View.GONE
@ -221,7 +216,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
// Show/hide the "Stats" overlay
updateShowPerformanceOverlay()
val position = IntSetting.PERFORMANCE_OVERLAY_POSITION.int
val position = Settings.settings.get(IntSetting.PERFORMANCE_OVERLAY_POSITION)
updateStatsPosition(position)
binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
@ -389,6 +384,27 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
true
}
R.id.menu_application_settings -> {
val titleId = NativeLibrary.getRunningTitleId()
if (titleId != 0L) {
val gameId = java.lang.String.format("%016X", titleId)
SettingsActivity.launch(
requireContext(),
SettingsFile.FILE_NAME_CONFIG,
gameId
)
} else {
// Fallback: open global settings if title id unknown
SettingsActivity.launch(
requireContext(),
SettingsFile.FILE_NAME_CONFIG,
""
)
}
true
}
R.id.menu_exit -> {
emulationState.pause()
MaterialAlertDialogBuilder(requireContext())
@ -508,7 +524,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
emulationState.unpause()
// If the overlay is enabled, we need to update the position if changed
val position = IntSetting.PERFORMANCE_OVERLAY_POSITION.int
val position = Settings.settings.get(IntSetting.PERFORMANCE_OVERLAY_POSITION)
updateStatsPosition(position)
binding.inGameMenu.menu.findItem(R.id.menu_emulation_pause)?.let { menuItem ->
@ -523,7 +539,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
if (DirectoryInitialization.areCitraDirectoriesReady()) {
emulationState.run(emulationActivity.isActivityRecreated)
emulationState.run(emulationActivity!!.isActivityRecreated)
} else {
setupCitraDirectoriesThenStartEmulation()
}
@ -538,6 +554,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
override fun onDetach() {
emulationActivity = null
NativeLibrary.clearEmulationActivity()
super.onDetach()
}
@ -557,7 +574,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
if (directoryInitializationState ===
DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED
) {
emulationState.run(emulationActivity.isActivityRecreated)
emulationState.run(emulationActivity!!.isActivityRecreated)
} else if (directoryInitializationState ===
DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED
) {
@ -707,7 +724,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
popupMenu.menu.apply {
findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay
findItem(R.id.menu_performance_overlay_show).isChecked =
BooleanSetting.PERF_OVERLAY_ENABLE.boolean
Settings.settings.get(BooleanSetting.PERF_OVERLAY_ENABLE)
findItem(R.id.menu_haptic_feedback).isChecked = EmulationMenuSettings.hapticFeedback
findItem(R.id.menu_emulation_joystick_rel_center).isChecked =
EmulationMenuSettings.joystickRelCenter
@ -724,8 +741,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
R.id.menu_performance_overlay_show -> {
BooleanSetting.PERF_OVERLAY_ENABLE.boolean = !BooleanSetting.PERF_OVERLAY_ENABLE.boolean
settings.saveSetting(BooleanSetting.PERF_OVERLAY_ENABLE, SettingsFile.FILE_NAME_CONFIG)
Settings.settings.update(BooleanSetting.PERF_OVERLAY_ENABLE,
Settings.settings.get(BooleanSetting.PERF_OVERLAY_ENABLE))
SettingsFile.saveSetting(BooleanSetting.PERF_OVERLAY_ENABLE, Settings.settings)
updateShowPerformanceOverlay()
true
}
@ -875,7 +893,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_emulation_amiibo_load -> {
emulationActivity.openAmiiboFileLauncher.launch(false)
emulationActivity!!.openAmiiboFileLauncher.launch(false)
true
}
@ -921,7 +939,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
popupMenu.menuInflater.inflate(R.menu.menu_landscape_screen_layout, popupMenu.menu)
val layoutOptionMenuItem = when (IntSetting.SCREEN_LAYOUT.int) {
val layoutOptionMenuItem = when (Settings.settings.get(IntSetting.SCREEN_LAYOUT)) {
ScreenLayout.ORIGINAL.int ->
R.id.menu_screen_layout_original
@ -993,7 +1011,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
popupMenu.menuInflater.inflate(R.menu.menu_portrait_screen_layout, popupMenu.menu)
val layoutOptionMenuItem = when (IntSetting.PORTRAIT_SCREEN_LAYOUT.int) {
val layoutOptionMenuItem = when (Settings.settings.get(IntSetting.PORTRAIT_SCREEN_LAYOUT)) {
PortraitScreenLayout.TOP_FULL_WIDTH.int ->
R.id.menu_portrait_layout_top_full
PortraitScreenLayout.ORIGINAL.int ->
@ -1255,12 +1273,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
binding.surfaceInputOverlay.resetButtonPlacement()
}
fun updateShowPerformanceOverlay() {
if (perfStatsUpdater != null) {
perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!)
}
if (BooleanSetting.PERF_OVERLAY_ENABLE.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_ENABLE)) {
val SYSTEM_FPS = 0
val FPS = 1
val SPEED = 2
@ -1275,11 +1295,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
val perfStats = NativeLibrary.getPerfStats()
val dividerString = "\u00A0\u2502 "
if (perfStats[FPS] > 0) {
if (BooleanSetting.PERF_OVERLAY_SHOW_FPS.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_SHOW_FPS)) {
sb.append(String.format("FPS:\u00A0%d", (perfStats[FPS] + 0.5).toInt()))
}
if (BooleanSetting.PERF_OVERLAY_SHOW_FRAMETIME.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_SHOW_FRAMETIME)) {
if (sb.isNotEmpty()) sb.append(dividerString)
sb.append(
String.format(
@ -1294,7 +1314,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
)
}
if (BooleanSetting.PERF_OVERLAY_SHOW_SPEED.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_SHOW_SPEED)) {
if (sb.isNotEmpty()) sb.append(dividerString)
sb.append(
String.format(
@ -1304,14 +1324,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
)
}
if (BooleanSetting.PERF_OVERLAY_SHOW_APP_RAM_USAGE.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_SHOW_APP_RAM_USAGE)) {
if (sb.isNotEmpty()) sb.append(dividerString)
val appRamUsage =
File("/proc/self/statm").readLines()[0].split(' ')[1].toLong() * 4096 / 1000000
sb.append("Process\u00A0RAM:\u00A0$appRamUsage\u00A0MB")
}
if (BooleanSetting.PERF_OVERLAY_SHOW_AVAILABLE_RAM.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_SHOW_AVAILABLE_RAM)) {
if (sb.isNotEmpty()) sb.append(dividerString)
context?.let { ctx ->
val activityManager =
@ -1324,14 +1344,14 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
}
}
if (BooleanSetting.PERF_OVERLAY_SHOW_BATTERY_TEMP.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_SHOW_BATTERY_TEMP)) {
if (sb.isNotEmpty()) sb.append(dividerString)
val batteryTemp = getBatteryTemperature()
val tempF = celsiusToFahrenheit(batteryTemp)
sb.append(String.format("%.1f°C/%.1f°F", batteryTemp, tempF))
}
if (BooleanSetting.PERF_OVERLAY_BACKGROUND.boolean) {
if (Settings.settings.get(BooleanSetting.PERF_OVERLAY_BACKGROUND)) {
binding.performanceOverlayShowText.setBackgroundResource(R.color.citra_transparent_black)
} else {
binding.performanceOverlayShowText.setBackgroundResource(0)

View File

@ -56,36 +56,18 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
isCancelable = false
view.requestFocus()
view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() }
if (setting!!.isButtonMappingSupported()) {
dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) }
}
if (setting!!.isAxisMappingSupported()) {
binding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) }
}
dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) }
binding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) }
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
}
binding.textTitle.text =
String.format(
getString(R.string.input_dialog_title),
getString(inputTypeId),
getString(setting!!.nameId)
setting!!.value,""
)
var messageResId: Int = R.string.input_dialog_description
if (setting!!.isAxisMappingSupported() && !setting!!.isTrigger()) {
// Use specialized message for axis left/right or up/down
messageResId = if (setting!!.isHorizontalOrientation()) {
R.string.input_binding_description_horizontal_axis
} else {
R.string.input_binding_description_vertical_axis
}
}
val messageResId: Int = R.string.input_dialog_description
binding.textMessage.text = getString(messageResId)
binding.buttonClear.setOnClickListener {
@ -140,7 +122,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
var numMovedAxis = 0
var axisMoveValue = 0.0f
var lastMovedRange: InputDevice.MotionRange? = null
var lastMovedDir = '?'
var lastMovedDir = 0
if (waitingForEvent) {
for (i in motionRanges.indices) {
val range = motionRanges[i]
@ -164,7 +146,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
axisMoveValue = origValue
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (origValue < 0.0f) '-' else '+'
lastMovedDir = if (origValue < 0.0f) -1 else 1
}
} 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
@ -172,7 +154,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
// due to the first press being caught by the "if (firstEvent)" case further up.
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (previousValue < 0.0f) '-' else '+'
lastMovedDir = if (previousValue < 0.0f) -1 else 1
}
}
previousValues[i] = origValue
@ -181,7 +163,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
// If only one axis moved, that's the winner.
if (numMovedAxis == 1) {
waitingForEvent = false
setting?.onMotionInput(input, lastMovedRange!!, lastMovedDir)
setting?.onMotionInput(lastMovedRange!!, lastMovedDir)
dismiss()
}
}

View File

@ -24,6 +24,7 @@ import androidx.preference.PreferenceManager
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.utils.EmulationMenuSettings
import org.citra.citra_emu.utils.TurboHelper
import java.lang.NullPointerException
@ -45,7 +46,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
private var buttonBeingConfigured: InputOverlayDrawableButton? = null
private var dpadBeingConfigured: InputOverlayDrawableDpad? = null
private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null
private val settingsViewModel = NativeLibrary.sEmulationActivity.get()!!.settingsViewModel
private lateinit var settings: Settings
// Stores the ID of the pointer that interacted with the 3DS touchscreen.
private var touchscreenPointerId = -1
@ -71,6 +72,10 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
requestFocus()
}
fun initializeSettings(settings: Settings) {
this.settings = settings
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
overlayButtons.forEach { it.draw(canvas) }
@ -168,12 +173,12 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
continue
}
anyOverlayStateChanged = true
// TODO - switch these to using standard hotkey buttons instead of nativelibrary buttons
if (button.id == NativeLibrary.ButtonType.BUTTON_SWAP && button.status == NativeLibrary.ButtonState.PRESSED) {
swapScreen()
}
else if (button.id == NativeLibrary.ButtonType.BUTTON_TURBO && button.status == NativeLibrary.ButtonState.PRESSED) {
TurboHelper.toggleTurbo(true)
TurboHelper.toggleTurbo(true, settings)
}
NativeLibrary.onGamePadEvent(

View File

@ -48,7 +48,6 @@ import org.citra.citra_emu.R
import org.citra.citra_emu.contracts.OpenFileResultContract
import org.citra.citra_emu.databinding.ActivityMainBinding
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.SettingsViewModel
import org.citra.citra_emu.features.settings.ui.SettingsActivity
import org.citra.citra_emu.features.settings.utils.SettingsFile
import org.citra.citra_emu.fragments.GrantMissingFilesystemPermissionFragment
@ -72,7 +71,6 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
private val homeViewModel: HomeViewModel by viewModels()
private val gamesViewModel: GamesViewModel by viewModels()
private val settingsViewModel: SettingsViewModel by viewModels()
override var themeId: Int = 0
@ -95,13 +93,15 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
if (PermissionsHandler.hasWriteAccess(applicationContext) &&
DirectoryInitialization.areCitraDirectoriesReady() &&
!CitraDirectoryUtils.needToUpdateManually()) {
settingsViewModel.settings.loadSettings()
}
ThemeUtil.ThemeChangeListener(this)
ThemeUtil.setTheme(this)
super.onCreate(savedInstanceState)
// load the global settings from the config file at program launch
SettingsFile.loadSettings(Settings.settings)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

View File

@ -1,69 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.utils
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
/**
* Some controllers have incorrect mappings. This class has special-case fixes for them.
*/
object ControllerMappingHelper {
/**
* Some controllers report extra button presses that can be ignored.
*/
fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
return if (isDualShock4(inputDevice)) {
// The two analog triggers generate analog motion events as well as a keycode.
// We always prefer to use the analog values, so throw away the button press
keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
} else false
}
/**
* Scale an axis to be zero-centered with a proper range.
*/
fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
if (isDualShock4(inputDevice)) {
// Android doesn't have correct mappings for this controller's triggers. It reports them
// as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
// Scale them to properly zero-centered with a range of [0.0, 1.0].
if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
return (value + 1) / 2.0f
}
} else if (isXboxOneWireless(inputDevice)) {
// Same as the DualShock 4, the mappings are missing.
if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
return (value + 1) / 2.0f
}
if (axis == MotionEvent.AXIS_GENERIC_1) {
// This axis is stuck at ~.5. Ignore it.
return 0.0f
}
} else if (isMogaPro2Hid(inputDevice)) {
// This controller has a broken axis that reports a constant value. Ignore it.
if (axis == MotionEvent.AXIS_GENERIC_1) {
return 0.0f
}
}
return value
}
private fun isDualShock4(inputDevice: InputDevice): Boolean {
// Sony DualShock 4 controller
return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
}
private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
// Microsoft Xbox One controller
return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
}
private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
// Moga Pro 2 HID
return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
}
}

View File

@ -9,6 +9,7 @@ import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.Settings
object TurboHelper {
private var turboSpeedEnabled = false
@ -17,12 +18,12 @@ object TurboHelper {
return turboSpeedEnabled
}
fun reloadTurbo(showToast: Boolean) {
fun reloadTurbo(showToast: Boolean, settings: Settings) {
val context = CitraApplication.appContext
val toastMessage: String
if (turboSpeedEnabled) {
NativeLibrary.setTemporaryFrameLimit(IntSetting.TURBO_LIMIT.int.toDouble())
NativeLibrary.setTemporaryFrameLimit(settings.get(IntSetting.TURBO_LIMIT).toDouble())
toastMessage = context.getString(R.string.turbo_enabled_toast)
} else {
NativeLibrary.disableTemporaryFrameLimit()
@ -34,12 +35,12 @@ object TurboHelper {
}
}
fun setTurboEnabled(state: Boolean, showToast: Boolean) {
fun setTurboEnabled(state: Boolean, showToast: Boolean, settings: Settings) {
turboSpeedEnabled = state
reloadTurbo(showToast)
reloadTurbo(showToast, settings)
}
fun toggleTurbo(showToast: Boolean) {
setTurboEnabled(!TurboHelper.isTurboSpeedEnabled(), showToast)
fun toggleTurbo(showToast: Boolean, settings: Settings) {
setTurboEnabled(!TurboHelper.isTurboSpeedEnabled(), showToast, settings)
}
}

View File

@ -1,4 +1,4 @@
// 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.
@ -7,9 +7,14 @@ package org.citra.citra_emu.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.utils.SettingsFile
class EmulationViewModel : ViewModel() {
val emulationStarted get() = _emulationStarted.asStateFlow()
// convenience shortcut for
val settings = Settings.settings
private val _emulationStarted = MutableStateFlow(false)
val shaderProgress get() = _shaderProgress.asStateFlow()
@ -21,6 +26,12 @@ class EmulationViewModel : ViewModel() {
val shaderMessage get() = _shaderMessage.asStateFlow()
private val _shaderMessage = MutableStateFlow("")
/** Used for the initial load of settings. Call rebuild for later creations. */
fun loadSettings(titleId: Long) {
if (settings.getAllGlobal().isNotEmpty()) return //already loaded
SettingsFile.loadSettings(settings, String.format("%016X", titleId))
}
fun setShaderProgress(progress: Int) {
_shaderProgress.value = progress
}

View File

@ -79,46 +79,79 @@ static const std::array<int, Settings::NativeAnalog::NumAnalogs> default_analogs
}};
template <>
void Config::ReadSetting(const std::string& group, Settings::Setting<std::string>& setting) {
std::string setting_value =
android_config->Get(group, setting.GetLabel(), setting.GetDefault());
std::string Config::GetSetting(const std::string& group, Settings::Setting<std::string>& setting) {
std::string setting_value = setting.GetDefault();
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
setting_value = per_game_config->Get(group, setting.GetLabel(), setting_value);
} else if (android_config) {
setting_value = android_config->Get(group, setting.GetLabel(), setting_value);
}
if (setting_value.empty()) {
setting_value = setting.GetDefault();
}
setting = std::move(setting_value);
return setting_value;
}
template <>
void Config::ReadSetting(const std::string& group, Settings::Setting<std::string>& setting) {
setting = std::move(GetSetting(group, setting));
}
template <>
bool Config::GetSetting(const std::string& group, Settings::Setting<bool>& setting) {
bool value = setting.GetDefault();
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
value = per_game_config->GetBoolean(group, setting.GetLabel(), value);
} else if (android_config) {
value = android_config->GetBoolean(group, setting.GetLabel(), value);
}
return value;
}
template <>
void Config::ReadSetting(const std::string& group, Settings::Setting<bool>& setting) {
setting = android_config->GetBoolean(group, setting.GetLabel(), setting.GetDefault());
setting = GetSetting(group, setting);
}
//TODO: figure out why ranged isn't being used
template <typename Type, bool ranged>
Type Config::GetSetting(const std::string& group, Settings::Setting<Type, ranged>& setting) {
if constexpr (std::is_floating_point_v<Type>) {
double value = static_cast<double>(setting.GetDefault());
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
value = per_game_config->GetReal(group, setting.GetLabel(), value);
} else if (android_config) {
value = android_config->GetReal(group, setting.GetLabel(), value);
}
return static_cast<Type>(value);
} else {
long value = static_cast<long>(setting.GetDefault());
if (per_game_config && per_game_config->HasValue(group, setting.GetLabel())) {
value = per_game_config->GetInteger(group, setting.GetLabel(), value);
} else if (android_config) {
value = android_config->GetInteger(group, setting.GetLabel(), value);
}
return static_cast<Type>(value);
}
}
template <typename Type, bool ranged>
void Config::ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting) {
if constexpr (std::is_floating_point_v<Type>) {
setting = android_config->GetReal(group, setting.GetLabel(), setting.GetDefault());
} else {
setting = static_cast<Type>(android_config->GetInteger(
group, setting.GetLabel(), static_cast<long>(setting.GetDefault())));
}
setting = GetSetting(group, setting);
}
void Config::ReadValues() {
// Controls
// Controls - Always use the default_buttons and default_analogs on the native side, don't
// actually read the INI file. It is only used by Kotlin, which will correctly dispatch
// the codes as needed.
for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) {
std::string default_param = InputManager::GenerateButtonParamPackage(default_buttons[i]);
Settings::values.current_input_profile.buttons[i] = android_config->GetString(
"Controls", Settings::NativeButton::mapping[i], default_param);
if (Settings::values.current_input_profile.buttons[i].empty())
Settings::values.current_input_profile.buttons[i] = default_param;
Settings::values.current_input_profile.buttons[i] =
InputManager::GenerateButtonParamPackage(default_buttons[i]);
}
for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) {
std::string default_param = InputManager::GenerateAnalogParamPackage(default_analogs[i]);
Settings::values.current_input_profile.analogs[i] = android_config->GetString(
"Controls", Settings::NativeAnalog::mapping[i], default_param);
if (Settings::values.current_input_profile.analogs[i].empty())
Settings::values.current_input_profile.analogs[i] = default_param;
Settings::values.current_input_profile.analogs[i] =
InputManager::GenerateAnalogParamPackage(default_analogs[i]);
}
Settings::values.current_input_profile.motion_device = android_config->GetString(
@ -139,9 +172,8 @@ void Config::ReadValues() {
ReadSetting("Core", Settings::values.cpu_clock_percentage);
// Renderer
Settings::values.use_gles = android_config->GetBoolean("Renderer", "use_gles", true);
Settings::values.shaders_accurate_mul =
android_config->GetBoolean("Renderer", "shaders_accurate_mul", false);
ReadSetting("Renderer",Settings::values.use_gles);
ReadSetting("Renderer",Settings::values.shaders_accurate_mul);
ReadSetting("Renderer", Settings::values.graphics_api);
ReadSetting("Renderer", Settings::values.async_presentation);
ReadSetting("Renderer", Settings::values.async_shader_compilation);
@ -156,7 +188,14 @@ void Config::ReadValues() {
ReadSetting("Renderer", Settings::values.texture_sampling);
ReadSetting("Renderer", Settings::values.turbo_limit);
// Workaround to map Android setting for enabling the frame limiter to the format Citra expects
if (android_config->GetBoolean("Renderer", "use_frame_limit", true)) {
// TODO: test this!
bool use_frame_limit = false;
if (per_game_config && per_game_config->HasValue("Renderer", "use_frame_limit")) {
use_frame_limit = per_game_config->GetBoolean("Renderer", "use_frame_limit", true);
} else if (android_config) {
use_frame_limit = android_config->GetBoolean("Renderer", "use_frame_limit", true);
}
if (use_frame_limit) {
ReadSetting("Renderer", Settings::values.frame_limit);
} else {
Settings::values.frame_limit = 0;
@ -183,21 +222,10 @@ void Config::ReadValues() {
ReadSetting("Renderer", Settings::values.swap_eyes_3d);
ReadSetting("Renderer", Settings::values.render_3d_which_display);
// Layout
// Somewhat inelegant solution to ensure layout value is between 0 and 5 on read
// since older config files may have other values
int layoutInt = (int)android_config->GetInteger(
"Layout", "layout_option", static_cast<int>(Settings::LayoutOption::LargeScreen));
if (layoutInt < 0 || layoutInt > 5) {
layoutInt = static_cast<int>(Settings::LayoutOption::LargeScreen);
}
Settings::values.layout_option = static_cast<Settings::LayoutOption>(layoutInt);
Settings::values.screen_gap =
static_cast<int>(android_config->GetReal("Layout", "screen_gap", 0));
Settings::values.large_screen_proportion =
static_cast<float>(android_config->GetReal("Layout", "large_screen_proportion", 2.25));
Settings::values.small_screen_position = static_cast<Settings::SmallScreenPosition>(
android_config->GetInteger("Layout", "small_screen_position",
static_cast<int>(Settings::SmallScreenPosition::TopRight)));
ReadSetting("Layout",Settings::values.layout_option);
ReadSetting("Layout",Settings::values.screen_gap);
ReadSetting("Layout", Settings::values.small_screen_position);
ReadSetting("Layout", Settings::values.screen_gap);
ReadSetting("Layout", Settings::values.custom_top_x);
ReadSetting("Layout", Settings::values.custom_top_y);
@ -212,14 +240,8 @@ void Config::ReadValues() {
ReadSetting("Layout", Settings::values.cardboard_x_shift);
ReadSetting("Layout", Settings::values.cardboard_y_shift);
ReadSetting("Layout", Settings::values.upright_screen);
Settings::values.portrait_layout_option =
static_cast<Settings::PortraitLayoutOption>(android_config->GetInteger(
"Layout", "portrait_layout_option",
static_cast<int>(Settings::PortraitLayoutOption::PortraitTopFullWidth)));
Settings::values.secondary_display_layout = static_cast<Settings::SecondaryDisplayLayout>(
android_config->GetInteger("Layout", Settings::HKeys::secondary_display_layout.c_str(),
static_cast<int>(Settings::SecondaryDisplayLayout::None)));
ReadSetting("Layout", Settings::values.portrait_layout_option);
ReadSetting("Layout", Settings::values.secondary_display_layout);
ReadSetting("Layout", Settings::values.custom_portrait_top_x);
ReadSetting("Layout", Settings::values.custom_portrait_top_y);
ReadSetting("Layout", Settings::values.custom_portrait_top_width);
@ -349,3 +371,37 @@ void Config::Reload() {
LoadINI(DefaultINI::android_config_default_file_content);
ReadValues();
}
void Config::LoadPerGameConfig(u64 title_id, const std::string& fallback_name) {
// Determine file name
std::string name;
if (title_id != 0) {
std::ostringstream ss;
ss << std::uppercase << std::hex << std::setw(16) << std::setfill('0') << title_id;
name = ss.str();
} else {
name = fallback_name;
}
if (name.empty()) {
per_game_config.reset();
per_game_config_loc.clear();
return;
}
const auto base = FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir);
per_game_config_loc = base + "custom/" + name + ".ini";
std::string ini_buffer;
FileUtil::ReadFileToString(true, per_game_config_loc, ini_buffer);
if (!ini_buffer.empty()) {
per_game_config = std::make_unique<INIReader>(ini_buffer.c_str(), ini_buffer.size());
if (per_game_config->ParseError() < 0) {
per_game_config.reset();
}
} else {
per_game_config.reset();
}
// Re-apply values so that per-game overrides (if any) take effect immediately.
ReadValues();
}

View File

@ -14,6 +14,8 @@ class Config {
private:
std::unique_ptr<INIReader> android_config;
std::string android_config_loc;
std::unique_ptr<INIReader> per_game_config;
std::string per_game_config_loc;
bool LoadINI(const std::string& default_contents = "", bool retry = true);
void ReadValues();
@ -23,14 +25,27 @@ public:
~Config();
void Reload();
// Load a per-game config overlay by title id or fallback name. Does not create files.
void LoadPerGameConfig(u64 title_id, const std::string& fallback_name = "");
private:
/**
* Applies a value read from the android_config to a Setting.
*
* @param group The name of the INI group
* @param setting The yuzu setting to modify
* @param setting The setting to modify
*/
template <typename Type, bool ranged>
void ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting);
/**
* Reads a value honoring per_game config, and returns it.
* Does not modify the setting.
*
* @param group The name of the INI group
* @param setting The setting to modify
*/
template <typename Type, bool ranged>
Type GetSetting(const std::string& group, Settings::Setting<Type, ranged>& setting);
};

View File

@ -77,6 +77,39 @@ static const char* android_config_default_file_content = (BOOST_HANA_STRING(R"(
# Use Artic Controller when connected to Artic Base Server. (Default 0)
)") DECLARE_KEY(use_artic_base_controller) BOOST_HANA_STRING(R"(
# Configuration strings for 3ds buttons and axes. On Android, all such strings use "engine:gamepad" once bound. Default unbound.
)") DECLARE_KEY(button_a) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_b) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_x) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_y) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_l) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_r) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_zl) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_zr) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_start) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_select) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_home) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_up) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_down) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_left) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_right) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_up) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_down) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_left) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_right) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_up) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_down) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_left) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_right) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_close) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_cycle_layout) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_enable) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_pause_resume) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_quickload) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_quicksave) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_swap) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_turbo_limit) BOOST_HANA_STRING(R"(
[Core]
# Whether to use the Just-In-Time (JIT) compiler for CPU emulation
# 0: Interpreter (slow), 1 (default): JIT (fast)
@ -88,6 +121,7 @@ static const char* android_config_default_file_content = (BOOST_HANA_STRING(R"(
# Range is any positive integer (but we suspect 25 - 400 is a good idea) Default is 100
)") DECLARE_KEY(cpu_clock_percentage) BOOST_HANA_STRING(R"(
[Renderer]
# Whether to render using OpenGL
# 1: OpenGL ES (default), 2: Vulkan

View File

@ -191,6 +191,28 @@ static Core::System::ResultStatus RunCitra(const std::string& filepath) {
const auto graphics_api = Settings::values.graphics_api.GetValue();
EGLContext* shared_context;
// Load game-specific settings overlay if available
u64 program_id{};
FileUtil::SetCurrentRomPath(filepath);
auto app_loader = Loader::GetLoader(filepath);
if (app_loader) {
app_loader->ReadProgramId(program_id);
system.RegisterAppLoaderEarly(app_loader);
}
// Forces a config reload on game boot, if the user changed settings in the UI
Config global_config{};
// Use filename as fallback if title id is zero (e.g., homebrew)
const std::string fallback_name =
program_id == 0 ? std::string(FileUtil::GetFilename(filepath)) : std::string{};
global_config.LoadPerGameConfig(program_id, fallback_name);
system.ApplySettings();
Settings::LogSettings();
switch (graphics_api) {
#ifdef ENABLE_OPENGL
case Settings::GraphicsAPI::OpenGL:
@ -228,18 +250,7 @@ static Core::System::ResultStatus RunCitra(const std::string& filepath) {
break;
}
// Forces a config reload on game boot, if the user changed settings in the UI
Config{};
// Replace with game-specific settings
u64 program_id{};
FileUtil::SetCurrentRomPath(filepath);
auto app_loader = Loader::GetLoader(filepath);
if (app_loader) {
app_loader->ReadProgramId(program_id);
system.RegisterAppLoaderEarly(app_loader);
}
system.ApplySettings();
Settings::LogSettings();
Camera::RegisterFactory("image", std::make_unique<Camera::StillImage::Factory>());
@ -913,13 +924,18 @@ void Java_org_citra_citra_1emu_NativeLibrary_logUserDirectory(JNIEnv* env,
void Java_org_citra_citra_1emu_NativeLibrary_reloadSettings([[maybe_unused]] JNIEnv* env,
[[maybe_unused]] jobject obj) {
Config{};
Config cfg{};
Core::System& system{Core::System::GetInstance()};
// Replace with game-specific settings
// Load game-specific settings overlay (if a game is running)
if (system.IsPoweredOn()) {
u64 program_id{};
system.GetAppLoader().ReadProgramId(program_id);
// Use the registered ROM path (if any) to derive a fallback name
const std::string current_rom_path = FileUtil::GetCurrentRomPath();
const std::string fallback_name =
program_id == 0 ? std::string(FileUtil::GetFilename(current_rom_path)) : std::string{};
cfg.LoadPerGameConfig(program_id, fallback_name);
}
system.ApplySettings();

View File

@ -180,6 +180,15 @@
android:contentDescription="@string/cheats"
android:text="@string/cheats" />
<com.google.android.material.button.MaterialButton
android:id="@+id/application_settings"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:contentDescription="@string/application_settings"
android:text="@string/application_settings" />
<com.google.android.material.button.MaterialButton
android:id="@+id/insert_cartridge_button"
style="@style/Widget.Material3.Button.TonalButton.Icon"

View File

@ -62,6 +62,15 @@
android:textSize="13sp"
tools:text="1x" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_use_global"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/use_global"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>

View File

@ -46,6 +46,15 @@
android:textAlignment="viewStart"
tools:text="@string/frame_limit_enable_description" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_use_global"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/use_global"
android:visibility="gone" />
</LinearLayout>
</RelativeLayout>

View File

@ -57,6 +57,11 @@
android:icon="@drawable/ic_settings"
android:title="@string/preferences_settings" />
<item
android:id="@+id/menu_application_settings"
android:icon="@drawable/ic_settings"
android:title="@string/application_settings" />
<item
android:id="@+id/menu_exit"
android:icon="@drawable/ic_exit"

View File

@ -522,6 +522,8 @@
<string name="menu_emulation_amiibo">Amiibo</string>
<string name="menu_emulation_amiibo_load">Load</string>
<string name="menu_emulation_amiibo_remove">Remove</string>
<string name="application_settings">Custom Settings</string>
<string name="use_global">Customized: Revert to Global</string>
<string name="select_amiibo">Select Amiibo File</string>
<string name="amiibo_load_error">Error Loading Amiibo</string>
<string name="amiibo_load_error_message">While loading the specified Amiibo file, an error occurred. Please check that the file is correct.</string>

View File

@ -1037,6 +1037,10 @@ void SetCurrentRomPath(const std::string& path) {
g_currentRomPath = path;
}
std::string GetCurrentRomPath() {
return g_currentRomPath;
}
bool StringReplace(std::string& haystack, const std::string& a, const std::string& b, bool swap) {
const auto& needle = swap ? b : a;
const auto& replacement = swap ? a : b;

View File

@ -213,6 +213,7 @@ bool SetCurrentDir(const std::string& directory);
void SetUserPath(const std::string& path = "");
void SetCurrentRomPath(const std::string& path);
[[nodiscard]] std::string GetCurrentRomPath();
// Returns a pointer to a string with a Citra data dir in the user's home
// directory. To be used in "multi-user" mode (that is, installed).