diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index b7094fe281..dd5dcdfcd3 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -102,6 +102,7 @@ import app.gamenative.R import app.gamenative.data.GyroSettings import app.gamenative.powercontrol.PowerManager import app.gamenative.ui.component.dialog.GyroSettingsDialog +import app.gamenative.ui.component.dialog.OnScreenControllerSettingsDialog import app.gamenative.ui.component.dialog.ControlProfileLibraryDialog import app.gamenative.ui.component.quickMenus.PowerControlQuickMenuTab import app.gamenative.ui.data.PerformanceHudConfig @@ -110,6 +111,7 @@ import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.util.adaptivePanelWidth import app.gamenative.utils.MathUtils.normalizedProgress import com.winlator.container.Container +import com.winlator.inputcontrols.ControlsProfile import com.winlator.renderer.GLRenderer import com.winlator.renderer.VulkanRenderer import com.winlator.winhandler.ProcessInfo @@ -539,6 +541,9 @@ fun QuickMenu( // broken D8 codegen path). val inviteMenu = remember(container?.id) { SteamInviteState.createIfAvailable(container) } var showGyroSettingsDialog by rememberSaveable(container?.id) { mutableStateOf(false) } + var onScreenControllerSettingsProfile by remember(container?.id) { + mutableStateOf(null) + } var showControlProfiles by rememberSaveable(container?.id) { mutableStateOf(false) } var lastControllerFocusRequester by remember(container?.id) { mutableStateOf(null) } var controlProfileReturnFocusRequester by remember(container?.id) { mutableStateOf(null) } @@ -1122,7 +1127,9 @@ fun QuickMenu( }, focusRequester = if (index == 0) controllerItemFocusRequester else null, onFocused = { lastControllerFocusRequester = it }, - secondaryIcon = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + secondaryIcon = if (item.id == QuickMenuAction.INPUT_CONTROLS && item.id in activeToggleIds) + Icons.Default.Settings + else if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) Icons.Default.Settings else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) Icons.Default.Settings @@ -1131,7 +1138,9 @@ fun QuickMenu( else if (item.id == QuickMenuAction.GYRO && gyroEnabled) Icons.Default.Settings else null, - secondaryContentDescriptionResId = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + secondaryContentDescriptionResId = if (item.id == QuickMenuAction.INPUT_CONTROLS && item.id in activeToggleIds) + R.string.on_screen_controller_settings + else if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) R.string.physical_controller_settings_title else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) R.string.gesture_settings_title @@ -1140,7 +1149,9 @@ fun QuickMenu( else if (item.id == QuickMenuAction.GYRO && gyroEnabled) R.string.gyro_settings_title else null, - onSecondaryClick = if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) + onSecondaryClick = if (item.id == QuickMenuAction.INPUT_CONTROLS && item.id in activeToggleIds) + ({ onScreenControllerSettingsProfile = PluviaApp.inputControlsView?.profile }) + else if (item.id == QuickMenuAction.EDIT_PHYSICAL_CONTROLLER) ({ onItemSelected(QuickMenuAction.PHYSICAL_CONTROLLER_SETTINGS) }) else if (item.id == QuickMenuAction.TOUCHSCREEN_MODE && touchscreenEnabled) onTouchGestureSettingsClick @@ -1183,6 +1194,19 @@ fun QuickMenu( ) } + onScreenControllerSettingsProfile?.let { profile -> + OnScreenControllerSettingsDialog( + initialCursorSpeed = profile.cursorSpeed, + onDismiss = { onScreenControllerSettingsProfile = null }, + onSave = { speed -> + profile.cursorSpeed = speed + profile.save() + PluviaApp.touchpadView?.setSensitivity(profile.cursorSpeed) + onScreenControllerSettingsProfile = null + }, + ) + } + if (showControlProfiles && container != null) { ControlProfileLibraryDialog( container = container, diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/OnScreenControllerSettingsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/OnScreenControllerSettingsDialog.kt new file mode 100644 index 0000000000..024e9cd0ea --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/dialog/OnScreenControllerSettingsDialog.kt @@ -0,0 +1,139 @@ +package app.gamenative.ui.component.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.gamenative.R +import app.gamenative.ui.theme.PluviaBackground +import com.winlator.inputcontrols.ControlsProfile +import java.util.Locale + +internal val DEFAULT_MOUSE_SPEED = ControlsProfile.DEFAULT_CURSOR_SPEED +internal const val MIN_MOUSE_SPEED = 0.1f +internal const val MAX_MOUSE_SPEED = 3.0f + +internal fun mouseSpeedOrDefault(value: Float): Float { + return value.takeIf { it.isFinite() && it > 0f } ?: DEFAULT_MOUSE_SPEED +} + +internal fun mouseSpeedForSlider(value: Float): Float { + return mouseSpeedOrDefault(value).coerceIn(MIN_MOUSE_SPEED, MAX_MOUSE_SPEED) +} + +internal fun mouseSpeedForSave(initialValue: Float, editedValue: Float, wasEdited: Boolean): Float { + return if (wasEdited) mouseSpeedForSlider(editedValue) else mouseSpeedOrDefault(initialValue) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnScreenControllerSettingsDialog( + initialCursorSpeed: Float, + onDismiss: () -> Unit, + onSave: (Float) -> Unit, +) { + var cursorSpeed by remember(initialCursorSpeed) { + mutableFloatStateOf(mouseSpeedForSlider(initialCursorSpeed)) + } + var cursorSpeedWasEdited by remember(initialCursorSpeed) { mutableStateOf(false) } + val locale = Locale.getDefault() + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = PluviaBackground, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.on_screen_controller_settings), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) + } + }, + actions = { + IconButton( + onClick = { + cursorSpeed = DEFAULT_MOUSE_SPEED + cursorSpeedWasEdited = true + }, + ) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.reset_mouse_speed), + ) + } + IconButton( + onClick = { + onSave(mouseSpeedForSave(initialCursorSpeed, cursorSpeed, cursorSpeedWasEdited)) + }, + ) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + SettingsDialogSectionHeader(stringResource(R.string.mouse)) + + SettingsSliderBlock( + title = stringResource(R.string.mouse_speed), + subtitle = stringResource(R.string.mouse_speed_subtitle), + value = cursorSpeed, + valueRange = MIN_MOUSE_SPEED..MAX_MOUSE_SPEED, + valueText = multiplierText( + mouseSpeedForSave(initialCursorSpeed, cursorSpeed, cursorSpeedWasEdited), + locale, + ), + onValueChange = { + cursorSpeed = it + cursorSpeedWasEdited = true + }, + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/SettingsDialogBlocks.kt b/app/src/main/java/app/gamenative/ui/component/dialog/SettingsDialogBlocks.kt index 36223bef31..545052e6e2 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/SettingsDialogBlocks.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/SettingsDialogBlocks.kt @@ -57,6 +57,7 @@ import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt import com.alorma.compose.settings.ui.SettingsSwitch import com.winlator.inputcontrols.Binding +import java.util.Locale import kotlin.math.ceil import kotlin.math.floor @@ -252,6 +253,10 @@ fun SettingsSliderBlock( if (compact) content() else GestureBlock { content() } } +internal fun multiplierText(value: Float, locale: Locale): String { + return String.format(locale, "%.1fx", value) +} + @Composable internal fun settingsSliderColors(showTickMarks: Boolean = true) = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ShooterModeSettingsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ShooterModeSettingsDialog.kt index 0347e013ca..54e5ec13bf 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ShooterModeSettingsDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ShooterModeSettingsDialog.kt @@ -409,10 +409,6 @@ private fun joystickBehaviorLabels(): List = listOf( stringResource(R.string.joystick_behavior_floating), ) -private fun multiplierText(value: Float, locale: Locale): String { - return String.format(locale, "%.1fx", value) -} - private fun pixelText(value: Float, locale: Locale): String { return String.format(locale, "%.1f px", value) } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt index 3da6c341cc..e5544fb90a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt @@ -365,7 +365,7 @@ class PhysicalControllerHandler( if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return@synchronized // Look up cursor speed dynamically so it updates when profile changes - val cursorSpeed = profile?.cursorSpeed ?: 1f + val cursorSpeed = profile?.cursorSpeed ?: ControlsProfile.DEFAULT_CURSOR_SPEED val scaledX = mouseMoveOffset.x * 10 * cursorSpeed + mouseMoveRemainder.x val scaledY = mouseMoveOffset.y * 10 * cursorSpeed + mouseMoveRemainder.y val deltaX = scaledX.toInt() diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index c404cacf89..2149c1d0d1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3392,7 +3392,7 @@ private fun showInputControls(profile: ControlsProfile, winHandler: WinHandler, } } - PluviaApp.touchpadView?.setSensitivity(profile.getCursorSpeed() * 1.0f) + PluviaApp.touchpadView?.setSensitivity(profile.cursorSpeed) } @@ -3435,7 +3435,7 @@ private fun hideInputControls() { PluviaApp.inputControlsView?.hideProfileForOverlay() PluviaApp.xServerView?.getxServer()?.winHandler?.refreshControllerMappingsForHotplug() - PluviaApp.touchpadView?.setSensitivity(1.0f) + PluviaApp.touchpadView?.setSensitivity(ControlsProfile.DEFAULT_CURSOR_SPEED) PluviaApp.touchpadView?.setPointerButtonLeftEnabled(true) PluviaApp.touchpadView?.setPointerButtonRightEnabled(true) PluviaApp.touchpadView?.isEnabled()?.let { diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index da57b5ab64..5fb10bf28a 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -97,6 +97,8 @@ public static String[] names() { private PointF currentPosition; private RangeScroller scroller; private CubicBezierInterpolator interpolator; + private final MouseDeltaAccumulator trackpadCursorX = new MouseDeltaAccumulator(); + private final MouseDeltaAccumulator trackpadCursorY = new MouseDeltaAccumulator(); private Object touchTime; private String shooterMovementType = "wasd"; private String shooterLookType = "mouse"; @@ -116,6 +118,27 @@ public ControlElement(InputControlsView inputControlsView) { this.inputControlsView = inputControlsView; } + static final class MouseDeltaAccumulator { + private float remainder; + + int scale(float value, float multiplier) { + if (value == 0.0f) return 0; + float scaledValue = value * multiplier + remainder; + int wholePixels = (int)scaledValue; + remainder = scaledValue - wholePixels; + return wholePixels; + } + + void reset() { + remainder = 0.0f; + } + } + + private void resetTrackpadCursorRemainders() { + trackpadCursorX.reset(); + trackpadCursorY.reset(); + } + private static Object[] createBindingSources(int count) { Object[] sources = new Object[count]; for (int i = 0; i < count; i++) sources[i] = new Object(); @@ -1293,6 +1316,7 @@ else if (type == Type.RANGE_BUTTON) { } else { if (type == Type.TRACKPAD) { + resetTrackpadCursorRemainders(); if (currentPosition == null) currentPosition = new PointF(); currentPosition.set(x, y); } @@ -1382,8 +1406,12 @@ public boolean handleTouchMove(int pointerId, float x, float y) { else if (type == Type.TRACKPAD) { final boolean[] states = {deltaY <= -TRACKPAD_MIN_SPEED, deltaX >= TRACKPAD_MIN_SPEED, deltaY >= TRACKPAD_MIN_SPEED, deltaX <= -TRACKPAD_MIN_SPEED}; if (handleRadialMenuDirectionalMove(pointerId, states, x, y)) return true; - int cursorDx = 0; - int cursorDy = 0; + ControlsProfile activeProfile = inputControlsView.getProfile(); + float cursorSpeed = activeProfile != null + ? activeProfile.getCursorSpeed() + : ControlsProfile.DEFAULT_CURSOR_SPEED; + float cursorDeltaX = 0; + float cursorDeltaY = 0; for (byte i = 0; i < 4; i++) { float value = (i == 1 || i == 3 ? deltaX : deltaY); @@ -1395,10 +1423,10 @@ else if (type == Type.TRACKPAD) { value *= TouchpadView.CURSOR_ACCELERATION; } if (mouseMoveBinding == Binding.MOUSE_MOVE_LEFT || mouseMoveBinding == Binding.MOUSE_MOVE_RIGHT) { - cursorDx = Mathf.roundPoint(value); + cursorDeltaX = value; } else { - cursorDy = Mathf.roundPoint(value); + cursorDeltaY = value; } boolean nextState = states[i]; if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { @@ -1446,6 +1474,8 @@ else if (this.states[i] != states[i]) { } } + int cursorDx = trackpadCursorX.scale(cursorDeltaX, cursorSpeed); + int cursorDy = trackpadCursorY.scale(cursorDeltaY, cursorSpeed); if (cursorDx != 0 || cursorDy != 0) inputControlsView.getXServer().injectPointerMoveDelta(cursorDx, cursorDy); } else { @@ -1525,6 +1555,8 @@ else if (!usesToggleSwitch() || selected) { else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { releaseActiveDirectionalStates(); + if (type == Type.TRACKPAD) resetTrackpadCursorRemainders(); + if (type == Type.RANGE_BUTTON) { scroller.handleTouchUp(); } @@ -1565,6 +1597,7 @@ else if (type == Type.BUTTON) { } else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { releaseActiveDirectionalStates(); + if (type == Type.TRACKPAD) resetTrackpadCursorRemainders(); if (type == Type.RANGE_BUTTON) scroller.cancelTouch(); currentPosition = null; } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 70042215a0..0c53e8cc1c 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -90,7 +90,7 @@ public static StickDigitalMode fromJsonName(String value) { public final int id; private String name; - private float cursorSpeed = DEFAULT_CURSOR_SPEED; + private volatile float cursorSpeed = DEFAULT_CURSOR_SPEED; private float leftStickDeadzone = DEFAULT_STICK_DEADZONE; private float rightStickDeadzone = DEFAULT_STICK_DEADZONE; private float leftStickSensitivity = DEFAULT_STICK_SENSITIVITY; @@ -161,7 +161,9 @@ public float getCursorSpeed() { } public void setCursorSpeed(float cursorSpeed) { - this.cursorSpeed = cursorSpeed; + this.cursorSpeed = Float.isFinite(cursorSpeed) && cursorSpeed > 0.0f + ? cursorSpeed + : DEFAULT_CURSOR_SPEED; } private static float clampDeadzone(float value) { diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 44c919e868..73b1642b5d 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -75,7 +75,7 @@ public class InputControlsView extends View { private float offsetX; private float offsetY; private ControlElement selectedElement; - private ControlsProfile profile; + private volatile ControlsProfile profile; // Retained while the overlay controls are hidden so gyro can keep targeting the active gamepad. private ControlsProfile gyroProfile; private float overlayOpacity = DEFAULT_OVERLAY_OPACITY; @@ -84,6 +84,7 @@ public class InputControlsView extends View { private final Bitmap[] icons = new Bitmap[40]; private Timer mouseMoveTimer; private final PointF mouseMoveOffset = new PointF(); + private final Object mouseMoveStateLock = new Object(); private final PointF mouseMoveRemainder = new PointF(); private boolean showTouchscreenControls = true; @@ -441,6 +442,8 @@ private void setProfileInternal(ControlsProfile profile, boolean preserveOverlay } } + if (profileChanged || profile == null) stopMouseMoveTimer(); + onControlsProfileContentChanged(profileChanged); gyroController.setHasProfile(profile != null); } @@ -451,6 +454,7 @@ public void hideProfileForOverlay() { synchronized (this) { this.profile = null; } + stopMouseMoveTimer(); } /** Re-evaluates latched gyro activation after the active profile is edited in place. */ @@ -575,8 +579,7 @@ public int getMaxWidth() { protected void onDetachedFromWindow() { cancelTouchRouting(); gyroController.onDetachedFromWindow(); - if (mouseMoveTimer != null) - mouseMoveTimer.cancel(); + stopMouseMoveTimer(); super.onDetachedFromWindow(); } @@ -590,30 +593,46 @@ public int getMaxHeight() { return (int)Mathf.roundTo(getHeight(), snappingSize); } - private void createMouseMoveTimer() { + private synchronized void createMouseMoveTimer() { if (profile != null && mouseMoveTimer == null) { - final float cursorSpeed = profile.getCursorSpeed(); mouseMoveTimer = new Timer(); mouseMoveTimer.schedule(new TimerTask() { @Override public void run() { - if (mouseMoveOffset.x == 0f) mouseMoveRemainder.x = 0f; - if (mouseMoveOffset.y == 0f) mouseMoveRemainder.y = 0f; - if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return; - - float scaledX = mouseMoveOffset.x * 10 * cursorSpeed + mouseMoveRemainder.x; - float scaledY = mouseMoveOffset.y * 10 * cursorSpeed + mouseMoveRemainder.y; - int deltaX = (int)scaledX; - int deltaY = (int)scaledY; - mouseMoveRemainder.set(scaledX - deltaX, scaledY - deltaY); - if (deltaX != 0 || deltaY != 0) { - xServer.injectPointerMoveDelta(deltaX, deltaY); + synchronized (mouseMoveStateLock) { + ControlsProfile currentProfile = profile; + if (currentProfile == null) return; + + if (mouseMoveOffset.x == 0f) mouseMoveRemainder.x = 0f; + if (mouseMoveOffset.y == 0f) mouseMoveRemainder.y = 0f; + if (mouseMoveOffset.x == 0f && mouseMoveOffset.y == 0f) return; + + float cursorSpeed = currentProfile.getCursorSpeed(); + float scaledX = mouseMoveOffset.x * 10 * cursorSpeed + mouseMoveRemainder.x; + float scaledY = mouseMoveOffset.y * 10 * cursorSpeed + mouseMoveRemainder.y; + int deltaX = (int)scaledX; + int deltaY = (int)scaledY; + mouseMoveRemainder.set(scaledX - deltaX, scaledY - deltaY); + if (deltaX != 0 || deltaY != 0) { + xServer.injectPointerMoveDelta(deltaX, deltaY); + } } } }, 0, 1000 / 60); } } + private synchronized void stopMouseMoveTimer() { + if (mouseMoveTimer != null) { + mouseMoveTimer.cancel(); + mouseMoveTimer = null; + } + synchronized (mouseMoveStateLock) { + mouseMoveOffset.set(0, 0); + mouseMoveRemainder.set(0, 0); + } + } + private void processJoystickInput(ExternalController controller) { ExternalControllerBinding controllerBinding; final int[] axes = {MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y}; @@ -1806,11 +1825,17 @@ else if (binding == Binding.ALT_ENTER) { return; } else if (binding == Binding.MOUSE_MOVE_LEFT || binding == Binding.MOUSE_MOVE_RIGHT) { - mouseMoveOffset.x = isActionDown ? (offset != 0 ? offset : (binding == Binding.MOUSE_MOVE_LEFT ? -1 : 1)) : 0; + synchronized (mouseMoveStateLock) { + mouseMoveOffset.x = isActionDown ? (offset != 0 ? offset : (binding == Binding.MOUSE_MOVE_LEFT ? -1 : 1)) : 0; + if (mouseMoveOffset.x == 0f) mouseMoveRemainder.x = 0f; + } if (isActionDown) createMouseMoveTimer(); } else if (binding == Binding.MOUSE_MOVE_DOWN || binding == Binding.MOUSE_MOVE_UP) { - mouseMoveOffset.y = isActionDown ? (offset != 0 ? offset : (binding == Binding.MOUSE_MOVE_UP ? -1 : 1)) : 0; + synchronized (mouseMoveStateLock) { + mouseMoveOffset.y = isActionDown ? (offset != 0 ? offset : (binding == Binding.MOUSE_MOVE_UP ? -1 : 1)) : 0; + if (mouseMoveOffset.y == 0f) mouseMoveRemainder.y = 0f; + } if (isActionDown) createMouseMoveTimer(); } else { diff --git a/app/src/main/java/com/winlator/widget/TouchpadView.java b/app/src/main/java/com/winlator/widget/TouchpadView.java index 3456791a7e..af08021727 100644 --- a/app/src/main/java/com/winlator/widget/TouchpadView.java +++ b/app/src/main/java/com/winlator/widget/TouchpadView.java @@ -15,6 +15,7 @@ import com.winlator.core.AppUtils; import com.winlator.inputcontrols.Binding; +import com.winlator.inputcontrols.ControlsProfile; import com.winlator.math.Mathf; import com.winlator.math.XForm; import com.winlator.renderer.ViewTransformation; @@ -243,7 +244,7 @@ public TouchpadView(Context context, XServer xServer, boolean capturePointerOnEx this.capturePointerOnExternalMouse = capturePointerOnExternalMouse; this.fingers = new Finger[4]; this.numFingers = (byte) 0; - this.sensitivity = 1.0f; + this.sensitivity = ControlsProfile.DEFAULT_CURSOR_SPEED; this.pointerButtonLeftEnabled = true; this.pointerButtonRightEnabled = true; this.moveCursorToTouchpoint = false; diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 173a935e4b..4452a3e3b1 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -243,6 +243,10 @@ D-Pad højre On-screen-controller Redigér on-screen-controller + Indstillinger for on-screen-controller + Musehastighed + Juster markørhastigheden for berøringsbevægelser og betjeningselementer, der er tildelt til at flytte musen. Shooter-tilstand bruger sin egen kigfølsomhed. + Nulstil musehastighed Redigér fysisk controller Afbrudt Nulstil on-screen-kontroller diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d02c996129..04977626ad 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -307,6 +307,10 @@ D-Pad rechts On-Screen-Controller On-Screen-Controller bearbeiten + Einstellungen für den On-Screen-Controller + Mausgeschwindigkeit + Passe die Zeigergeschwindigkeit für Touch-Bewegungen und Steuerelemente an, die zum Bewegen der Maus zugewiesen sind. Der Shooter-Modus verwendet eine eigene Blickempfindlichkeit. + Mausgeschwindigkeit zurücksetzen Physische Controller bearbeiten Verbindung getrennt On-Screen-Steuerung zurücksetzen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d9dcaf2345..86d8a5c855 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -325,6 +325,10 @@ Cruceta derecha Controles en pantalla Editar controles en pantalla + Ajustes de controles en pantalla + Velocidad del ratón + Ajusta la velocidad del puntero para el movimiento táctil y los controles asignados a mover el ratón. El modo shooter usa su propia sensibilidad de vista. + Restablecer velocidad del ratón Editar mando físico Desconectado Restablecer controles en pantalla diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 48918ac095..4bc7732fb8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -315,6 +315,10 @@ D-Pad Droite Contrôleur à l\'écran Modifier le contrôleur à l\'écran + Paramètres du contrôleur à l\'écran + Vitesse de la souris + Règle la vitesse du pointeur pour les mouvements tactiles et les commandes assignées au déplacement de la souris. Le mode tireur utilise sa propre sensibilité du regard. + Réinitialiser la vitesse de la souris Modifier le contrôleur physique Déconnecté Réinitialiser les contrôles à l\'écran diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 994007374b..3ebd847885 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -314,6 +314,10 @@ D-Pad Destra Controller a schermo Modifica Controller a schermo + Impostazioni controller a schermo + Velocità del mouse + Regola la velocità del puntatore per i movimenti touch e i controlli assegnati al movimento del mouse. La modalità sparatutto usa una sensibilità di visuale separata. + Reimposta velocità del mouse Modifica Controller Fisico Disconnesso Reimposta Controlli a Schermo diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d42982399f..fff1eba3c0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -268,6 +268,10 @@ 十字キー右 オンスクリーンコントローラー オンスクリーンコントローラーの編集 + オンスクリーンコントローラー設定 + マウス速度 + タッチ操作およびマウス移動に割り当てられたコントロールのポインター速度を調整します。シューターモードでは独自の視点感度が使用されます。 + マウス速度をリセット 物理コントローラーの編集 切断されました オンスクリーンコントロールをリセットする diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fd11e80649..4f4128288e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -322,6 +322,10 @@ 십자 버튼 오른쪽 화면 컨트롤러 화면 컨트롤러 편집 + 화면 컨트롤러 설정 + 마우스 속도 + 터치 이동 및 마우스 이동에 할당된 컨트롤의 포인터 속도를 조절합니다. 슈터 모드는 별도의 시점 감도를 사용합니다. + 마우스 속도 재설정 물리 컨트롤러 편집 연결 끊김 화면 컨트롤 초기화 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8ed141c477..ff421bf856 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -322,6 +322,10 @@ D-Pad Prawo Kontroler ekranowy Edytuj kontroler ekranowy + Ustawienia kontrolera ekranowego + Prędkość myszy + Dostosuj prędkość wskaźnika dla ruchu dotykowego i elementów sterujących przypisanych do poruszania myszą. Tryb strzelanki używa własnej czułości rozglądania. + Resetuj prędkość myszy Edytuj kontroler fizyczny Rozłączono Zresetuj kontroler ekranowy diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 52809b670b..1b9e4f6b71 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -243,6 +243,10 @@ D-Pad Direita Controle On-screen Editar Controle On-screen + Configurações do controle na tela + Velocidade do mouse + Ajuste a velocidade do ponteiro para movimentos por toque e controles atribuídos ao movimento do mouse. O modo tiro usa sua própria sensibilidade de visão. + Redefinir velocidade do mouse Editar Controle Físico Desconectado Redefinir Controles On-screen diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 675d90bfbe..9af4052271 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -312,6 +312,10 @@ D‑Pad Dreapta Controller pe ecran Editează controllerul pe ecran + Setări controller pe ecran + Viteza mouse-ului + Reglează viteza indicatorului pentru mișcarea tactilă și comenzile atribuite deplasării mouse-ului. Modul shooter folosește propria sensibilitate a privirii. + Resetează viteza mouse-ului Editează controllerul fizic Deconectat Resetează controalele pe ecran diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1e9c9e7716..471e7cd674 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -664,6 +664,10 @@ В процессе Частично отмечено Экранный контроллер + Настройки экранного контроллера + Скорость мыши + Настройте скорость указателя для сенсорного перемещения и элементов управления, назначенных на движение мыши. Режим шутера использует отдельную чувствительность обзора. + Сбросить скорость мыши Установить Установить в любом случае Установить diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a400710589..69c4caf668 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -309,6 +309,10 @@ D-Pad вправо Елементи керування введенням Редагувати екранний контролер + Налаштування екранного контролера + Швидкість миші + Налаштуйте швидкість вказівника для руху дотиком і елементів керування, призначених для руху миші. Режим шутера використовує окрему чутливість огляду. + Скинути швидкість миші Редагувати фізичний контролер Від\'єднано Скинути екранні елементи керування diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f02aef8eb0..21e8e2c9f3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -308,6 +308,10 @@ 方向键 右 屏幕控制器 编辑屏幕控制器 + 屏幕控制器设置 + 鼠标速度 + 调整触摸移动和分配为移动鼠标的控件的指针速度。射击模式使用独立的视角灵敏度。 + 重置鼠标速度 编辑实体控制器 已断开 重置屏幕控制器 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2cd2c40a6a..74904d49b3 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -308,6 +308,10 @@ 方向鍵 右 螢幕控制器 編輯螢幕控制器 + 螢幕控制器設定 + 滑鼠速度 + 調整觸控移動及指派為移動滑鼠之控制項的指標速度。射擊模式使用獨立的視角靈敏度。 + 重設滑鼠速度 編輯實體控制器 已斷線 重置螢幕控制器 diff --git a/app/src/test/java/app/gamenative/ui/component/dialog/OnScreenControllerSettingsDialogTest.kt b/app/src/test/java/app/gamenative/ui/component/dialog/OnScreenControllerSettingsDialogTest.kt new file mode 100644 index 0000000000..6d9aafb102 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/component/dialog/OnScreenControllerSettingsDialogTest.kt @@ -0,0 +1,34 @@ +package app.gamenative.ui.component.dialog + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OnScreenControllerSettingsDialogTest { + @Test + fun `invalid stored speeds fall back to default`() { + assertEquals(DEFAULT_MOUSE_SPEED, mouseSpeedOrDefault(Float.NaN), 0f) + assertEquals(DEFAULT_MOUSE_SPEED, mouseSpeedOrDefault(-1f), 0f) + assertEquals(DEFAULT_MOUSE_SPEED, mouseSpeedOrDefault(0f), 0f) + } + + @Test + fun `valid stored speeds remain unchanged before slider clamping`() { + assertEquals(4f, mouseSpeedOrDefault(4f), 0f) + } + + @Test + fun `slider speed is constrained to the supported range`() { + assertEquals(MIN_MOUSE_SPEED, mouseSpeedForSlider(0.01f), 0f) + assertEquals(MAX_MOUSE_SPEED, mouseSpeedForSlider(4f), 0f) + } + + @Test + fun `saving without editing preserves a valid value outside the slider range`() { + assertEquals(4f, mouseSpeedForSave(4f, MAX_MOUSE_SPEED, false), 0f) + } + + @Test + fun `saving an edited value uses the slider range`() { + assertEquals(MAX_MOUSE_SPEED, mouseSpeedForSave(4f, 4f, true), 0f) + } +} diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementMouseSpeedTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementMouseSpeedTest.kt new file mode 100644 index 0000000000..482c20d0ee --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementMouseSpeedTest.kt @@ -0,0 +1,55 @@ +package com.winlator.inputcontrols + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ControlElementMouseSpeedTest { + @Test + fun `default trackpad speed accumulates transformed subpixels`() { + val positive = ControlElement.MouseDeltaAccumulator() + val negative = ControlElement.MouseDeltaAccumulator() + + assertEquals(listOf(0, 0, 0, 0, 1), List(5) { positive.scale(0.2f, 1f) }) + assertEquals(listOf(0, 0, 0, 0, -1), List(5) { negative.scale(-0.2f, 1f) }) + } + + @Test + fun `sub one trackpad speed remains proportional`() { + val accumulator = ControlElement.MouseDeltaAccumulator() + + assertEquals( + listOf(0, 0, 0, 0, 0, 0, 0, 1), + List(8) { accumulator.scale(0.25f, 0.5f) }, + ) + } + + @Test + fun `above one trackpad speed remains proportional`() { + val accumulator = ControlElement.MouseDeltaAccumulator() + + assertEquals( + listOf(0, 1, 1, 1), + List(4) { accumulator.scale(0.25f, 3f) }, + ) + } + + @Test + fun `direction reversal cancels the pending subpixel`() { + val accumulator = ControlElement.MouseDeltaAccumulator() + + assertEquals(0, accumulator.scale(0.75f, 1f)) + assertEquals(0, accumulator.scale(-0.75f, 1f)) + assertEquals(0, accumulator.scale(-0.5f, 1f)) + assertEquals(-1, accumulator.scale(-0.5f, 1f)) + } + + @Test + fun `reset discards a pending subpixel`() { + val accumulator = ControlElement.MouseDeltaAccumulator() + + assertEquals(0, accumulator.scale(0.75f, 1f)) + accumulator.reset() + assertEquals(0, accumulator.scale(0.5f, 1f)) + assertEquals(1, accumulator.scale(0.5f, 1f)) + } +} diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlsProfileCursorSpeedTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlsProfileCursorSpeedTest.kt new file mode 100644 index 0000000000..62908e3859 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/ControlsProfileCursorSpeedTest.kt @@ -0,0 +1,45 @@ +package com.winlator.inputcontrols + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets + +@RunWith(RobolectricTestRunner::class) +class ControlsProfileCursorSpeedTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val profile = ControlsProfile(context, 1) + + @Test + fun `cursor speed accepts positive finite values`() { + profile.cursorSpeed = 2.5f + + assertEquals(2.5f, profile.cursorSpeed, 0f) + } + + @Test + fun `cursor speed rejects values that would stop or corrupt movement`() { + listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, 0f, -1f).forEach { value -> + profile.cursorSpeed = value + + assertEquals(ControlsProfile.DEFAULT_CURSOR_SPEED, profile.cursorSpeed, 0f) + } + } + + @Test + fun `legacy profile without cursor speed loads the default`() { + val json = """{"id":7,"name":"Legacy"}""" + val loadedProfile = InputControlsManager.loadProfile( + context, + ByteArrayInputStream(json.toByteArray(StandardCharsets.UTF_8)), + ) + + assertNotNull(loadedProfile) + assertEquals(ControlsProfile.DEFAULT_CURSOR_SPEED, loadedProfile!!.cursorSpeed, 0f) + } +} diff --git a/app/src/test/java/com/winlator/widget/InputControlsViewMouseSpeedTest.kt b/app/src/test/java/com/winlator/widget/InputControlsViewMouseSpeedTest.kt new file mode 100644 index 0000000000..0ab46af37f --- /dev/null +++ b/app/src/test/java/com/winlator/widget/InputControlsViewMouseSpeedTest.kt @@ -0,0 +1,111 @@ +package com.winlator.widget + +import androidx.test.core.app.ApplicationProvider +import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.xserver.XServer +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import java.util.Timer +import java.util.TimerTask + +@RunWith(RobolectricTestRunner::class) +class InputControlsViewMouseSpeedTest { + @Test + fun `fractional movement accumulates and uses updated profile speed`() = withTimer { view, profile, xServer, tasks -> + view.handleInputEvent(Binding.MOUSE_MOVE_RIGHT, true, 0.05f) + val task = tasks.single() + + task.run() + verify(xServer, never()).injectPointerMoveDelta(any(), any()) + task.run() + verify(xServer).injectPointerMoveDelta(1, 0) + + profile.cursorSpeed = 2f + task.run() + verify(xServer, org.mockito.kotlin.times(2)).injectPointerMoveDelta(1, 0) + verify(xServer, never()).injectPointerMoveDelta(0, 0) + } + + @Test + fun `releasing an axis discards its pending fraction before another press`() = withTimer { view, _, xServer, tasks -> + view.handleInputEvent(Binding.MOUSE_MOVE_RIGHT, true, 0.05f) + tasks.single().run() + view.handleInputEvent(Binding.MOUSE_MOVE_RIGHT, false) + view.handleInputEvent(Binding.MOUSE_MOVE_RIGHT, true, 0.05f) + + tasks.single().run() + verify(xServer, never()).injectPointerMoveDelta(any(), any()) + tasks.single().run() + verify(xServer).injectPointerMoveDelta(1, 0) + } + + @Test + fun `switching profiles clears movement and restarts with the new speed`() = withTimer { view, _, xServer, tasks -> + view.handleInputEvent(Binding.MOUSE_MOVE_RIGHT, true, 0.05f) + val oldTask = tasks.single() + oldTask.run() + + val newProfile = ControlsProfile(ApplicationProvider.getApplicationContext(), 2).apply { cursorSpeed = 2f } + view.setProfile(newProfile) + oldTask.run() + verify(xServer, never()).injectPointerMoveDelta(any(), any()) + + view.handleInputEvent(Binding.MOUSE_MOVE_RIGHT, true, 0.025f) + assertEquals(2, tasks.size) + tasks.last().run() + verify(xServer, never()).injectPointerMoveDelta(any(), any()) + tasks.last().run() + verify(xServer).injectPointerMoveDelta(1, 0) + } + + @Test + fun `hiding and re-enabling controls restarts without stale movement`() = withTimer { view, profile, xServer, tasks -> + view.handleInputEvent(Binding.MOUSE_MOVE_DOWN, true, 0.05f) + val oldTask = tasks.single() + oldTask.run() + view.hideProfileForOverlay() + oldTask.run() + view.setProfile(profile) + oldTask.run() + verify(xServer, never()).injectPointerMoveDelta(any(), any()) + + view.handleInputEvent(Binding.MOUSE_MOVE_DOWN, true, 0.05f) + assertEquals(2, tasks.size) + tasks.last().run() + verify(xServer, never()).injectPointerMoveDelta(any(), any()) + tasks.last().run() + verify(xServer).injectPointerMoveDelta(0, 1) + } + + private fun withTimer(block: (InputControlsView, ControlsProfile, XServer, List) -> Unit) { + val context = ApplicationProvider.getApplicationContext() + val view = InputControlsView(context) + val profile = ControlsProfile(context, 1) + val xServer = mock() + val tasks = mutableListOf() + view.setXServer(xServer) + mockConstruction(Timer::class.java) { timer, _ -> + doAnswer { invocation -> + tasks.add(invocation.getArgument(0)) + null + }.`when`(timer).schedule(any(), eq(0L), eq((1000 / 60).toLong())) + }.use { + try { + view.setProfile(profile) + block(view, profile, xServer, tasks) + } finally { + view.setProfile(null) + } + } + } +}