From 170457b07027100ddfc10164699746bc0f86a7b2 Mon Sep 17 00:00:00 2001 From: Wei Liu Date: Tue, 9 Sep 2025 16:20:09 +0800 Subject: [PATCH] Added bottom hot corners --- .../citra_emu/fragments/EmulationFragment.kt | 173 ++++++++++++++- .../citra_emu/overlay/HotCornerOverlay.kt | 210 ++++++++++++++++++ .../citra/citra_emu/overlay/InputOverlay.kt | 6 + .../citra_emu/utils/HotCornerSettings.kt | 58 +++++ .../main/res/layout/fragment_emulation.xml | 10 + .../main/res/menu/menu_overlay_options.xml | 24 +- .../src/main/res/values-b+zh+CN/strings.xml | 15 ++ .../app/src/main/res/values/dimens.xml | 3 + .../app/src/main/res/values/strings.xml | 15 ++ 9 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/HotCornerOverlay.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/HotCornerSettings.kt diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt index 419919527..6d63b3a5a 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt @@ -8,6 +8,7 @@ import android.annotation.SuppressLint import android.app.ActivityManager import android.content.Context import android.content.DialogInterface +import android.content.res.Configuration import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences @@ -49,6 +50,7 @@ import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder +import androidx.appcompat.app.AlertDialog import com.google.android.material.slider.Slider import java.io.File import kotlinx.coroutines.flow.collectLatest @@ -74,6 +76,9 @@ import org.citra.citra_emu.model.Game 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.overlay.HotCornerOverlay +import org.citra.citra_emu.utils.HotCornerSettings +import org.citra.citra_emu.utils.TurboHelper import org.citra.citra_emu.utils.FileUtil import org.citra.citra_emu.utils.GameHelper import org.citra.citra_emu.utils.GameIconUtils @@ -103,7 +108,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram private val settingsViewModel: SettingsViewModel by viewModels() private val settings get() = settingsViewModel.settings - private val onPause = Runnable{ togglePause() } + private val onPause = Runnable{ togglePauseAndSyncMenu() } private val onShutdown = Runnable{ emulationState.stop() } override fun onAttach(context: Context) { @@ -183,6 +188,32 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram } binding.surfaceEmulation.holder.addCallback(this) + // Setup hot corner overlay + binding.hotCornerOverlay.apply { + actionListener = object : HotCornerOverlay.OnActionListener { + override fun onHotCornerAction(action: HotCornerSettings.HotCornerAction) { + when (action) { + HotCornerSettings.HotCornerAction.NONE -> {} + HotCornerSettings.HotCornerAction.PAUSE_RESUME -> togglePauseAndSyncMenu() + HotCornerSettings.HotCornerAction.TOGGLE_TURBO -> TurboHelper.toggleTurbo(true) + HotCornerSettings.HotCornerAction.QUICK_SAVE -> { + NativeLibrary.saveState(NativeLibrary.QUICKSAVE_SLOT) + Toast.makeText(context, getString(R.string.saving), Toast.LENGTH_SHORT).show() + } + HotCornerSettings.HotCornerAction.QUICK_LOAD -> { + val loaded = NativeLibrary.loadStateIfAvailable(NativeLibrary.QUICKSAVE_SLOT) + val resId = if (loaded) R.string.loading else R.string.quickload_not_found + Toast.makeText(context, getString(resId), Toast.LENGTH_SHORT).show() + } + HotCornerSettings.HotCornerAction.OPEN_MENU -> { + if (!binding.drawerLayout.isOpen) binding.drawerLayout.open() + } + HotCornerSettings.HotCornerAction.SWAP_SCREENS -> screenAdjustmentUtil.swapScreen() + } + } + } + refresh() + } binding.doneControlConfig.setOnClickListener { binding.doneControlConfig.visibility = View.GONE binding.surfaceInputOverlay.setIsInEditMode(false) @@ -471,9 +502,23 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram } } + private fun togglePauseAndSyncMenu() { + togglePause() + binding.inGameMenu.menu.findItem(R.id.menu_emulation_pause)?.let { menuItem -> + if (emulationState.isPaused) { + menuItem.title = resources.getString(R.string.resume_emulation) + menuItem.icon = ResourcesCompat.getDrawable(resources, R.drawable.ic_play, requireContext().theme) + } else { + menuItem.title = resources.getString(R.string.pause_emulation) + menuItem.icon = ResourcesCompat.getDrawable(resources, R.drawable.ic_pause, requireContext().theme) + } + } + } + override fun onResume() { super.onResume() Choreographer.getInstance().postFrameCallback(this) + binding.hotCornerOverlay.refresh() if (NativeLibrary.isRunning()) { emulationState.pause() @@ -823,6 +868,28 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram true } + R.id.menu_hot_corner_radius -> { + runAfterDrawerClosed { showHotCornerRadiusDialog() } + true + } + + R.id.menu_hot_corner_portrait_bl -> { + showHotCornerSelectDialog(Configuration.ORIENTATION_PORTRAIT, HotCornerSettings.HotCornerPosition.BOTTOM_LEFT) + true + } + R.id.menu_hot_corner_portrait_br -> { + showHotCornerSelectDialog(Configuration.ORIENTATION_PORTRAIT, HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT) + true + } + R.id.menu_hot_corner_landscape_bl -> { + showHotCornerSelectDialog(Configuration.ORIENTATION_LANDSCAPE, HotCornerSettings.HotCornerPosition.BOTTOM_LEFT) + true + } + R.id.menu_hot_corner_landscape_br -> { + showHotCornerSelectDialog(Configuration.ORIENTATION_LANDSCAPE, HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT) + true + } + else -> true } } @@ -830,6 +897,110 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram popupMenu.show() } + private fun showHotCornerSelectDialog(orientation: Int, position: HotCornerSettings.HotCornerPosition) { + val actions = arrayOf( + getString(R.string.hot_corner_action_none), + getString(R.string.hot_corner_action_pause_resume), + getString(R.string.hot_corner_action_toggle_turbo), + getString(R.string.hot_corner_action_quick_save), + getString(R.string.hot_corner_action_quick_load), + getString(R.string.hot_corner_action_open_menu), + getString(R.string.hot_corner_action_swap_screens) + ) + + val values = HotCornerSettings.HotCornerAction.values() + val current = HotCornerSettings.getAction(orientation, position) + var selectedIndex = values.indexOf(current) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.hot_corner_settings) + .setSingleChoiceItems(actions, selectedIndex) { _: DialogInterface?, which: Int -> + selectedIndex = which + } + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + HotCornerSettings.setAction(orientation, position, values[selectedIndex]) + binding.hotCornerOverlay.refresh() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showHotCornerRadiusDialog() { + val sliderBinding = DialogSliderBinding.inflate(layoutInflater) + val max = 125 + val min = 0 + val previousDp = HotCornerSettings.getRadiusDp() + var currentDp = previousDp + + sliderBinding.apply { + slider.valueFrom = min.toFloat() + slider.valueTo = max.toFloat() + slider.value = previousDp.toFloat() + textValue.setText(previousDp.toString()) + textInput.suffixText = "dp" + slider.addOnChangeListener { _: Slider, value: Float, _: Boolean -> + currentDp = value.toInt() + if (textValue.text.toString() != currentDp.toString()) { + textValue.setText(currentDp.toString()) + textValue.setSelection(textValue.length()) + } + // Live preview + binding.hotCornerOverlay.showPreview(true) + binding.hotCornerOverlay.updatePreviewRadiusDp(currentDp) + } + } + + // Ensure preview is visible with current value before interaction + binding.hotCornerOverlay.showPreview(true) + binding.hotCornerOverlay.updatePreviewRadiusDp(previousDp) + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.hot_corner_radius) + .setView(sliderBinding.root) + .setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int -> + // Revert to previous value + HotCornerSettings.setRadiusDp(previousDp) + binding.hotCornerOverlay.showPreview(false) + binding.hotCornerOverlay.refresh() + } + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + // Persist current value + HotCornerSettings.setRadiusDp(currentDp) + binding.hotCornerOverlay.showPreview(false) + binding.hotCornerOverlay.refresh() + } + .setNeutralButton(R.string.slider_default, null) + .create() + + dialog.setOnShowListener { + val neutral = dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + neutral.setOnClickListener { + currentDp = 72 + sliderBinding.slider.value = 72f + binding.hotCornerOverlay.showPreview(true) + binding.hotCornerOverlay.updatePreviewRadiusDp(currentDp) + } + } + dialog.show() + } + + private fun runAfterDrawerClosed(action: () -> Unit) { + if (!binding.drawerLayout.isOpen) { + action() + return + } + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) {} + override fun onDrawerOpened(drawerView: View) {} + override fun onDrawerStateChanged(newState: Int) {} + override fun onDrawerClosed(drawerView: View) { + binding.drawerLayout.removeDrawerListener(this) + action() + } + }) + binding.drawerLayout.close() + } + private fun showAmiiboMenu() { val popupMenu = PopupMenu( requireContext(), diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/HotCornerOverlay.kt b/src/android/app/src/main/java/org/citra/citra_emu/overlay/HotCornerOverlay.kt new file mode 100644 index 000000000..4d80b9b6d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/HotCornerOverlay.kt @@ -0,0 +1,210 @@ +// 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.overlay + +import android.content.Context +import android.content.res.Configuration +import android.util.AttributeSet +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.view.Gravity +import android.view.View +import android.view.MotionEvent +import android.widget.FrameLayout +import org.citra.citra_emu.R +import org.citra.citra_emu.utils.HotCornerSettings + +class HotCornerOverlay @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + interface OnActionListener { + fun onHotCornerAction(action: HotCornerSettings.HotCornerAction) + } + + var actionListener: OnActionListener? = null + private var previewEnabled = false + private var previewRadiusPx: Int? = null + + init { + isClickable = false + isFocusable = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + } + + fun refresh() { + removeAllViews() + + val orientation = resources.configuration.orientation + val radiusDp = HotCornerSettings.getRadiusDp() + val sizePx = previewRadiusPx ?: (radiusDp * resources.displayMetrics.density).toInt() + + addCornerIfNeeded( + sizePx, + Gravity.BOTTOM or Gravity.START, + HotCornerSettings.getAction(orientation, HotCornerSettings.HotCornerPosition.BOTTOM_LEFT) + ) + + addCornerIfNeeded( + sizePx, + Gravity.BOTTOM or Gravity.END, + HotCornerSettings.getAction(orientation, HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT) + ) + + if (previewEnabled) addPreviewOverlay(sizePx) + } + + private fun addCornerIfNeeded(sizePx: Int, gravity: Int, action: HotCornerSettings.HotCornerAction) { + if (action == HotCornerSettings.HotCornerAction.NONE) return + + val position = if ((gravity and Gravity.START) == Gravity.START) { + HotCornerSettings.HotCornerPosition.BOTTOM_LEFT + } else { + HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT + } + + val v = QuarterCircleTouchView(context, position, sizePx).apply { + layoutParams = LayoutParams(sizePx, sizePx, gravity) + contentDescription = action.name + setOnClickListener { actionListener?.onHotCornerAction(action) } + } + addView(v) + } + + fun showPreview(enable: Boolean) { + previewEnabled = enable + if (!enable) previewRadiusPx = null + refresh() + } + + fun updatePreviewRadiusDp(dp: Int) { + previewRadiusPx = (dp * resources.displayMetrics.density).toInt() + refresh() + } + + private fun addPreviewOverlay(sizePx: Int) { + val overlayColor = 0x80FF0000.toInt() // 50% alpha red + + val left = QuarterCirclePreviewView( + context, + HotCornerSettings.HotCornerPosition.BOTTOM_LEFT, + sizePx, + overlayColor + ).apply { + layoutParams = LayoutParams(sizePx, sizePx, Gravity.BOTTOM or Gravity.START) + } + + val right = QuarterCirclePreviewView( + context, + HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT, + sizePx, + overlayColor + ).apply { + layoutParams = LayoutParams(sizePx, sizePx, Gravity.BOTTOM or Gravity.END) + } + + addView(left) + addView(right) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + refresh() + } +} + +private class QuarterCircleTouchView( + context: Context, + private val position: HotCornerSettings.HotCornerPosition, + private val radiusPx: Int +) : View(context) { + private var downInside = false + + init { + isClickable = true + isFocusable = false + background = null + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val inside = isInsideQuarterCircle(event.x, event.y) + return when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (inside) { + downInside = true + true + } else { + downInside = false + false + } + } + MotionEvent.ACTION_UP -> { + val handled = downInside && inside + if (handled) performClick() + downInside = false + handled + } + MotionEvent.ACTION_CANCEL -> { + downInside = false + false + } + else -> downInside + } + } + + override fun performClick(): Boolean { + return super.performClick() + } + + private fun isInsideQuarterCircle(x: Float, y: Float): Boolean { + val r = radiusPx.toFloat() + val (cx, cy) = when (position) { + HotCornerSettings.HotCornerPosition.BOTTOM_LEFT -> 0f to r + HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT -> r to r + } + + val dx = x - cx + val dy = y - cy + // Constrain to the visible quadrant only + val inQuadrant = when (position) { + HotCornerSettings.HotCornerPosition.BOTTOM_LEFT -> (dx >= 0f && dy <= 0f) + HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT -> (dx <= 0f && dy <= 0f) + } + return inQuadrant && (dx * dx + dy * dy <= r * r) + } +} + +private class QuarterCirclePreviewView( + context: Context, + private val position: HotCornerSettings.HotCornerPosition, + private val radiusPx: Int, + color: Int +) : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + this.color = color + } + private val oval = RectF() + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val r = radiusPx.toFloat() + when (position) { + HotCornerSettings.HotCornerPosition.BOTTOM_LEFT -> { + // Center at (0, r), rect spans [-r, 0]..[r, 2r] + oval.set(-r, 0f, r, 2f * r) + canvas.drawArc(oval, 270f, 90f, true, paint) + } + HotCornerSettings.HotCornerPosition.BOTTOM_RIGHT -> { + // Center at (r, r), rect spans [0, 0]..[2r, 2r] + oval.set(0f, 0f, 2f * r, 2f * r) + canvas.drawArc(oval, 180f, 90f, true, paint) + } + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt index f7519bb81..8b25909ac 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt @@ -598,6 +598,12 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex fun setIsInEditMode(isInEditMode: Boolean) { this.isInEditMode = isInEditMode + // Hide hot corners while editing overlay to avoid drag conflicts + try { + val root = rootView + val hotCorner = root.findViewById(R.id.hot_corner_overlay) + hotCorner?.visibility = if (isInEditMode) View.GONE else View.VISIBLE + } catch (_: Exception) {} } private fun defaultOverlay() { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/HotCornerSettings.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/HotCornerSettings.kt new file mode 100644 index 000000000..89ec537fb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/HotCornerSettings.kt @@ -0,0 +1,58 @@ +// 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.utils + +import android.content.res.Configuration +import androidx.preference.PreferenceManager +import org.citra.citra_emu.CitraApplication + +/** + * Stores per-orientation hot corner actions. + * Defaults: + * - Portrait: BL=TOGGLE_TURBO, BR=PAUSE_RESUME + * - Landscape: BL=PAUSE_RESUME, BR=NONE + */ +object HotCornerSettings { + private val preferences = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + private const val KEY_RADIUS_DP = "HotCorner_radius_dp" + + enum class HotCornerPosition { BOTTOM_LEFT, BOTTOM_RIGHT } + + enum class HotCornerAction(val keySuffix: String) { + NONE("none"), + PAUSE_RESUME("pause_resume"), + TOGGLE_TURBO("toggle_turbo"), + QUICK_SAVE("quick_save"), + QUICK_LOAD("quick_load"), + OPEN_MENU("open_menu"), + SWAP_SCREENS("swap_screens") + } + + private fun key(orientation: Int, position: HotCornerPosition): String { + val orient = if (orientation == Configuration.ORIENTATION_LANDSCAPE) "land" else "port" + val pos = if (position == HotCornerPosition.BOTTOM_RIGHT) "br" else "bl" + return "HotCorner_${orient}_${pos}_action" + } + + fun getAction(orientation: Int, position: HotCornerPosition): HotCornerAction { + val stored = preferences.getString(key(orientation, position), null) + val defaultAction = HotCornerAction.NONE + val value = stored ?: defaultAction.name + return runCatching { HotCornerAction.valueOf(value) }.getOrElse { defaultAction } + } + + fun setAction(orientation: Int, position: HotCornerPosition, action: HotCornerAction) { + preferences.edit().putString(key(orientation, position), action.name).apply() + } + + fun getRadiusDp(): Int { + return preferences.getInt(KEY_RADIUS_DP, 72) + } + + fun setRadiusDp(value: Int) { + preferences.edit().putInt(KEY_RADIUS_DP, value).apply() + } +} \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index 214270df1..6acef12ed 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -32,6 +32,16 @@ android:focusableInTouchMode="true" android:visibility="invisible" /> + + +