From c1c258bbd0500078ba345163fadaa84a17cc862b Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Sun, 12 Jul 2026 23:52:02 -0500 Subject: [PATCH 1/8] Add multi-action input bindings --- .../app/gamenative/data/TouchGestureConfig.kt | 83 ++++++ .../dialog/ControllerBindingDialog.kt | 109 +++++-- .../component/dialog/ElementEditorDialog.kt | 44 +-- .../dialog/PhysicalControllerConfigSection.kt | 37 +-- .../component/dialog/SettingsDialogBlocks.kt | 31 +- .../dialog/TouchGestureSettingsDialog.kt | 272 +++++++++++++++--- .../xserver/InputControlsProfileCopy.kt | 2 +- .../xserver/PhysicalControllerHandler.kt | 237 +++++++++++++-- .../winlator/inputcontrols/BindingCombo.java | 227 +++++++++++++++ .../inputcontrols/ControlElement.java | 81 +++--- .../inputcontrols/ControlsProfile.java | 21 +- .../ExternalControllerBinding.java | 23 +- .../winlator/widget/InputControlsView.java | 65 ++++- .../com/winlator/widget/TouchpadView.java | 181 +++++++++++- app/src/main/res/values-da/strings.xml | 4 + app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values-pl/strings.xml | 4 + app/src/main/res/values-pt-rBR/strings.xml | 4 + app/src/main/res/values-ro/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-uk/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values-zh-rTW/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../gamenative/data/TouchGestureConfigTest.kt | 32 +++ .../inputcontrols/BindingComboTest.kt | 80 ++++++ 31 files changed, 1399 insertions(+), 186 deletions(-) create mode 100644 app/src/main/java/com/winlator/inputcontrols/BindingCombo.java create mode 100644 app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt diff --git a/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt b/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt index ad25a7f8ba..42940e71da 100644 --- a/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt +++ b/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt @@ -145,6 +145,13 @@ data class TouchGestureConfig( const val ACTION_OPEN_RADIAL_MENU = "open_radial_menu" const val ACTION_KEY_ESC = "key_ESC" const val ACTION_KEY_TILDE = "key_TILDE" + const val ACTION_COMBO_PREFIX = "combo:" + const val ACTION_SEQUENCE_PREFIX = "seq:" + const val ACTION_COMBO_SEPARATOR = "|" + const val MAX_ACTION_COMBO_SIZE = 3 + const val DEFAULT_ACTION_SEQUENCE_DELAY_MS = 150 + const val MIN_ACTION_SEQUENCE_DELAY_MS = 80 + const val MAX_ACTION_SEQUENCE_DELAY_MS = 1000 // ── Action identifiers: two-finger drag (pan) ─────────────────── const val PAN_WASD = "wasd" @@ -317,5 +324,81 @@ data class TouchGestureConfig( ZOOM_PLUS_MINUS, ZOOM_PAGE_UP_DOWN, ) + + fun actionComboOf( + actions: List, + sequence: Boolean = false, + sequenceDelayMs: Int = DEFAULT_ACTION_SEQUENCE_DELAY_MS, + ): String { + val filtered = actions + .filter { it.isNotBlank() } + .distinct() + .take(MAX_ACTION_COMBO_SIZE) + val normalized = if (sequence) filtered else filtered.sortedBy { actionComboSortGroup(it) } + return when (normalized.size) { + 0 -> ACTION_LEFT_CLICK + 1 -> normalized.first() + else -> if (sequence) { + ACTION_SEQUENCE_PREFIX + + sequenceDelayMs.coerceIn(MIN_ACTION_SEQUENCE_DELAY_MS, MAX_ACTION_SEQUENCE_DELAY_MS) + + ":" + + normalized.joinToString(ACTION_COMBO_SEPARATOR) + } else { + ACTION_COMBO_PREFIX + normalized.joinToString(ACTION_COMBO_SEPARATOR) + } + } + } + + fun actionParts(action: String?): List { + if (action.isNullOrBlank()) return emptyList() + val sequence = action.startsWith(ACTION_SEQUENCE_PREFIX) + val combo = action.startsWith(ACTION_COMBO_PREFIX) + if (!sequence && !combo) return listOf(action) + val parts = actionPayload(action, sequence) + .split(ACTION_COMBO_SEPARATOR) + .filter { it.isNotBlank() } + .distinct() + .take(MAX_ACTION_COMBO_SIZE) + return if (sequence) parts else parts.sortedBy { actionComboSortGroup(it) } + } + + fun isActionSequence(action: String?): Boolean { + return action?.startsWith(ACTION_SEQUENCE_PREFIX) == true + } + + fun actionSequenceDelayMs(action: String?): Int { + if (!isActionSequence(action)) return DEFAULT_ACTION_SEQUENCE_DELAY_MS + val payload = action.orEmpty().removePrefix(ACTION_SEQUENCE_PREFIX) + val separatorIndex = payload.indexOf(':') + if (separatorIndex <= 0) return DEFAULT_ACTION_SEQUENCE_DELAY_MS + return payload.substring(0, separatorIndex) + .toIntOrNull() + ?.coerceIn(MIN_ACTION_SEQUENCE_DELAY_MS, MAX_ACTION_SEQUENCE_DELAY_MS) + ?: DEFAULT_ACTION_SEQUENCE_DELAY_MS + } + + fun primaryAction(action: String?): String { + return actionParts(action).lastOrNull() ?: ACTION_LEFT_CLICK + } + + private fun actionPayload(action: String, sequence: Boolean): String { + if (!sequence) return action.removePrefix(ACTION_COMBO_PREFIX) + val payload = action.removePrefix(ACTION_SEQUENCE_PREFIX) + val separatorIndex = payload.indexOf(':') + return if (separatorIndex > 0 && payload.substring(0, separatorIndex).all { it.isDigit() }) { + payload.substring(separatorIndex + 1) + } else { + payload + } + } + + private fun actionComboSortGroup(action: String): Int { + return when (action) { + "key_CTRL_L", "key_CTRL_R", + "key_SHIFT_L", "key_SHIFT_R", + "key_ALT_L", "key_ALT_R" -> 0 + else -> 1 + } + } } } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt index a8de434ee7..bfbc8429ac 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.gamenative.ui.component.NoExtractOutlinedTextField import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.BindingCombo /** * Dialog for selecting controller button bindings. @@ -40,13 +41,34 @@ import com.winlator.inputcontrols.Binding fun ControllerBindingDialog( buttonName: String, currentBinding: Binding?, + currentBindingCombo: BindingCombo? = currentBinding?.let { BindingCombo.of(it) }, onDismiss: () -> Unit, - onBindingSelected: (Binding?) -> Unit + onBindingSelected: (Binding?) -> Unit, + onBindingComboSelected: ((BindingCombo?) -> Unit)? = null ) { var searchQuery by remember { mutableStateOf("") } var selectedCategory by remember { mutableStateOf(0) } // 0 = Keyboard, 1 = Mouse, 2 = Gamepad, 3 = Extra, null = All var isSearchExpanded by remember { mutableStateOf(false) } + val selectedBindings = remember(currentBindingCombo, currentBinding) { + mutableStateListOf().apply { + addAll((currentBindingCombo ?: BindingCombo.of(currentBinding)).bindings) + } + } + var selectedMode by remember(currentBindingCombo, currentBinding) { + mutableStateOf((currentBindingCombo ?: BindingCombo.of(currentBinding)).mode) + } + var selectedSequenceDelayMs by remember(currentBindingCombo, currentBinding) { + mutableIntStateOf((currentBindingCombo ?: BindingCombo.of(currentBinding)).sequenceDelayMs) + } + + fun submitSelection(combo: BindingCombo?) { + if (onBindingComboSelected != null) { + onBindingComboSelected(combo) + } else { + onBindingSelected(combo?.primaryBinding) + } + } // Get bindings by category val keyboardBindings = remember { Binding.keyboardBindingValues().toList() } @@ -142,21 +164,39 @@ fun ControllerBindingDialog( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) - if (currentBinding != null) { - Text( - text = stringResource(app.gamenative.R.string.current_binding, currentBinding.toString()), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + val combo = BindingCombo.fromBindings(selectedBindings, selectedMode, selectedSequenceDelayMs) + val comboLabel = if (combo.isSequence) { + stringResource( + app.gamenative.R.string.binding_sequence_current_format, + combo.toString(), + combo.sequenceDelayMs, ) + } else { + combo.toString() } + Text( + text = stringResource(app.gamenative.R.string.current_binding, comboLabel), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) } - // Close button - IconButton( - onClick = onDismiss, - modifier = Modifier.size(40.dp) - ) { - Icon(Icons.Default.Close, null) + Row { + IconButton( + onClick = { + val combo = BindingCombo.fromBindings(selectedBindings, selectedMode, selectedSequenceDelayMs) + submitSelection(if (combo.isEmpty) null else combo) + }, + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Default.Check, null) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Default.Close, null) + } } } @@ -249,6 +289,31 @@ fun ControllerBindingDialog( modifier = Modifier.padding(bottom = 4.dp) ) + if (selectedBindings.size > 1) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = selectedMode == BindingCombo.Mode.SIMULTANEOUS, + onClick = { selectedMode = BindingCombo.Mode.SIMULTANEOUS }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + label = { Text(stringResource(app.gamenative.R.string.binding_mode_simultaneous)) }, + ) + SegmentedButton( + selected = selectedMode == BindingCombo.Mode.SEQUENCE, + onClick = { selectedMode = BindingCombo.Mode.SEQUENCE }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + label = { Text(stringResource(app.gamenative.R.string.binding_mode_sequence)) }, + ) + } + if (selectedMode == BindingCombo.Mode.SEQUENCE) { + DelayTextField( + label = stringResource(app.gamenative.R.string.binding_sequence_delay_ms), + value = selectedSequenceDelayMs, + valueRange = BindingCombo.MIN_SEQUENCE_DELAY_MS..BindingCombo.MAX_SEQUENCE_DELAY_MS, + onValueChange = { selectedSequenceDelayMs = it }, + ) + } + } + // Helper function to render category button @Composable fun CategoryButton( @@ -331,14 +396,14 @@ fun ControllerBindingDialog( ) // Clear Binding button - more compact for smaller screens - if (currentBinding != null) { + if (selectedBindings.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Surface( modifier = Modifier .fillMaxWidth() .clickable { Log.d("ControllerBindingDialog", "Clearing binding for $buttonName") - onBindingSelected(null) + submitSelection(null) }, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), shape = MaterialTheme.shapes.small @@ -392,10 +457,15 @@ fun ControllerBindingDialog( filteredBindings.forEach { binding -> BindingOption( binding = binding, - isSelected = binding == currentBinding, + isSelected = binding in selectedBindings, + enabled = binding in selectedBindings || selectedBindings.size < BindingCombo.MAX_BINDINGS, onClick = { - Log.d("ControllerBindingDialog", "Binding selected for $buttonName: ${binding.name}") - onBindingSelected(binding) + if (binding in selectedBindings) { + selectedBindings.remove(binding) + } else if (selectedBindings.size < BindingCombo.MAX_BINDINGS) { + selectedBindings.add(binding) + } + Log.d("ControllerBindingDialog", "Binding toggled for $buttonName: ${binding.name}") } ) } @@ -411,12 +481,13 @@ fun ControllerBindingDialog( fun BindingOption( binding: Binding, isSelected: Boolean, + enabled: Boolean = true, onClick: () -> Unit ) { Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(enabled = enabled, onClick = onClick), color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else @@ -435,6 +506,8 @@ fun BindingOption( style = MaterialTheme.typography.bodyLarge, color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else if (!enabled) + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt index f4ad52e9de..c6477b2a9d 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt @@ -28,6 +28,7 @@ import app.gamenative.ui.theme.settingsTileColorsAlt import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.SettingsSwitch +import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlElement import com.winlator.widget.InputControlsView import java.util.Locale @@ -38,9 +39,15 @@ import kotlin.math.roundToInt * on-screen control buttons. Keeping this in one place so the derivation in * [ElementEditorDialog] stays consistent with [ControlElement.getDisplayText]. */ -private fun bindingShortLabel(binding: com.winlator.inputcontrols.Binding?): String { - if (binding == null) return "" - return binding.toString() +private fun bindingShortLabel(binding: com.winlator.inputcontrols.Binding?): String = + bindingShortLabelText(binding?.toString()) + +private fun bindingShortLabel(bindingCombo: BindingCombo?): String = + bindingShortLabelText(bindingCombo?.toString()) + +private fun bindingShortLabelText(value: String?): String { + if (value == null) return "" + return value .replace("NUMPAD ", "NP") .replace("BUTTON ", "") .replace("SHOW KEYBOARD", "KEY") @@ -74,7 +81,7 @@ fun ElementEditorDialog( // Store original bindings for restore on cancel val originalBindings by remember { mutableStateOf( - (0 until element.bindingCount).map { element.getBindingAt(it) } + (0 until element.bindingCount).map { element.getBindingComboAt(it) } ) } @@ -90,8 +97,8 @@ fun ElementEditorDialog( customText } else { // Show what's actually displayed (based on first binding) - val binding = element.getBindingAt(0) - if (binding != null && binding != com.winlator.inputcontrols.Binding.NONE) { + val binding = element.getBindingComboAt(0) + if (!binding.isEmpty) { var text = bindingShortLabel(binding) if (text.length > 7) { // Abbreviate long binding names (e.g., "KEY A B" -> "KAB") @@ -100,7 +107,7 @@ fun ElementEditorDialog( for (part in parts) { if (part.isNotEmpty()) sb.append(part[0]) } - text = (if (binding.isMouse) "M" else "") + sb.toString() + text = (if (binding.primaryBinding.isMouse) "M" else "") + sb.toString() } text } else { @@ -559,8 +566,8 @@ fun ElementEditorDialog( } for (i in 0 until bindingCount) { - val binding = element.getBindingAt(i) - val bindingName = binding?.toString() ?: "NONE" + val binding = element.getBindingComboAt(i) + val bindingName = binding.toString() val slotLabel = when (element.type) { ControlElement.Type.BUTTON -> { @@ -1027,16 +1034,19 @@ fun ElementEditorDialog( // Show binding selector dialog bindingSlotToEdit?.let { (slotIndex, slotLabel) -> val currentBinding = element.getBindingAt(slotIndex) + val currentBindingCombo = element.getBindingComboAt(slotIndex) ControllerBindingDialog( buttonName = slotLabel, currentBinding = currentBinding, + currentBindingCombo = currentBindingCombo, onDismiss = { bindingSlotToEdit = null }, - onBindingSelected = { binding -> + onBindingSelected = {}, + onBindingComboSelected = { bindingCombo -> // Update binding in memory only (not saved to disk yet) - if (binding != null) { - element.setBindingAt(slotIndex, binding) + if (bindingCombo != null) { + element.setBindingComboAt(slotIndex, bindingCombo) } else { element.setBindingAt(slotIndex, com.winlator.inputcontrols.Binding.NONE) } @@ -1046,13 +1056,13 @@ fun ElementEditorDialog( if (element.type == ControlElement.Type.BUTTON && slotIndex == 0) { // Check if custom text is empty or same as old binding text val customText = element.text - if (customText.isNullOrEmpty() || customText == bindingShortLabel(currentBinding)) { + if (customText.isNullOrEmpty() || customText == bindingShortLabel(currentBindingCombo)) { // Clear custom text so new binding text will show element.setText(null) currentTextEdited = false // Update currentText state to show what will actually be displayed (new binding text) - val newBindingText = bindingShortLabel(binding) + val newBindingText = bindingShortLabel(bindingCombo) currentText = if (newBindingText.length > 7) { // Abbreviate long names to match getDisplayText() logic val parts = newBindingText.split(" ") @@ -1060,7 +1070,7 @@ fun ElementEditorDialog( for (part in parts) { if (part.isNotEmpty()) sb.append(part[0]) } - (if (binding?.isMouse() == true) "M" else "") + sb.toString() + (if (bindingCombo?.primaryBinding?.isMouse() == true) "M" else "") + sb.toString() } else { newBindingText } @@ -1114,9 +1124,7 @@ fun ElementEditorDialog( } // Restore original bindings originalBindings.forEachIndexed { index, binding -> - if (binding != null) { - element.setBindingAt(index, binding) - } + element.setBindingComboAt(index, binding) } // Restore original shooter mode properties element.shooterMovementType = originalMovementType diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt index c2224f0875..8438037d81 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.gamenative.R import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalControllerBinding @@ -67,7 +68,7 @@ internal fun PhysicalControllerConfigSection( for (binding in defaultController.getControllerBindings()) { val newBinding = ExternalControllerBinding() newBinding.setKeyCode(binding.getKeyCodeForAxis()) - newBinding.setBinding(binding.getBinding()) + newBinding.setBindingCombo(binding.getBindingCombo()) ctrl.addControllerBinding(newBinding) } @@ -102,17 +103,17 @@ internal fun PhysicalControllerConfigSection( // Create a snapshot of original bindings for cancel behavior val originalBindings = remember { controller?.getControllerBindings()?.map { - it.getKeyCodeForAxis() to it.getBinding() + it.getKeyCodeForAxis() to it.getBindingCombo() }?.toMap() ?: emptyMap() } // Working copy of bindings (memory only until Save is clicked) - val workingBindings = remember { mutableStateMapOf() } + val workingBindings = remember { mutableStateMapOf() } // Initialize working copy with current bindings LaunchedEffect(controller) { controller?.getControllerBindings()?.forEach { - workingBindings[it.getKeyCodeForAxis()] = it.getBinding() + workingBindings[it.getKeyCodeForAxis()] = it.getBindingCombo() } } @@ -199,7 +200,7 @@ internal fun PhysicalControllerConfigSection( for ((keyCode, binding) in originalBindings) { val newBinding = ExternalControllerBinding() newBinding.setKeyCode(keyCode) - newBinding.setBinding(binding) + newBinding.setBindingCombo(binding) ctrl.addControllerBinding(newBinding) } } @@ -233,7 +234,7 @@ internal fun PhysicalControllerConfigSection( for ((keyCode, binding) in originalBindings) { val newBinding = ExternalControllerBinding() newBinding.setKeyCode(keyCode) - newBinding.setBinding(binding) + newBinding.setBindingCombo(binding) ctrl.addControllerBinding(newBinding) } } @@ -255,13 +256,13 @@ internal fun PhysicalControllerConfigSection( if (defaultControllers.isNotEmpty()) { val defaultController = defaultControllers[0] for (binding in defaultController.getControllerBindings()) { - workingBindings[binding.getKeyCodeForAxis()] = binding.getBinding() + workingBindings[binding.getKeyCodeForAxis()] = binding.getBindingCombo() } } } // Ensure Home/Guide/PS button is always set to OPEN_NAVIGATION_MENU - workingBindings[KeyEvent.KEYCODE_BUTTON_MODE] = com.winlator.inputcontrols.Binding.OPEN_NAVIGATION_MENU + workingBindings[KeyEvent.KEYCODE_BUTTON_MODE] = BindingCombo.of(com.winlator.inputcontrols.Binding.OPEN_NAVIGATION_MENU) Log.d("gncontrol", "Set Home button (KEYCODE_BUTTON_MODE) to OPEN_NAVIGATION_MENU") refreshKey++ @@ -282,7 +283,7 @@ internal fun PhysicalControllerConfigSection( if (binding != null) { val newBinding = ExternalControllerBinding() newBinding.setKeyCode(keyCode) - newBinding.setBinding(binding) + newBinding.setBindingCombo(binding) ctrl.addControllerBinding(newBinding) } } @@ -537,9 +538,11 @@ internal fun PhysicalControllerConfigSection( ControllerBindingDialog( buttonName = label, - currentBinding = currentBinding, + currentBinding = currentBinding?.primaryBinding, + currentBindingCombo = currentBinding, onDismiss = { showBindingDialog = null }, - onBindingSelected = { binding -> + onBindingSelected = {}, + onBindingComboSelected = { binding -> if (binding != null) { workingBindings[keyCode] = binding Log.d("gncontrol", "Updated binding for keyCode $keyCode to $binding") @@ -584,7 +587,7 @@ private fun CategoryButton( private fun ControllerBindingItem( label: String, keyCode: Int, - workingBindings: Map, + workingBindings: Map, onClick: () -> Unit ) { val binding = workingBindings[keyCode] @@ -629,7 +632,7 @@ private fun PhysicalControlPresets( leftStickAxes: List, rightStickAxes: List, dpadButtons: List, - workingBindings: MutableMap, + workingBindings: MutableMap, onPresetsApplied: () -> Unit = {} ) { Card( @@ -793,7 +796,7 @@ private fun applyPhysicalPreset( leftStickAxes: List, rightStickAxes: List, dpadButtons: List, - workingBindings: MutableMap + workingBindings: MutableMap ) { // Define bindings for each preset (Up, Down, Left, Right order for sticks; Up, Down, Left, Right for dpad buttons) val bindings = when (preset) { @@ -860,7 +863,7 @@ private fun applyPhysicalPreset( // Apply bindings keyCodes.forEachIndexed { index, keyCode -> if (keyCode != 0 && index < bindings.size) { - workingBindings[keyCode] = bindings[index] + workingBindings[keyCode] = BindingCombo.of(bindings[index]) } } } @@ -900,8 +903,8 @@ private fun copyElementsIfNeeded(context: android.content.Context, destProfile: if (element.has("bindings")) { val bindings = element.getJSONArray("bindings") for (j in 0 until bindings.length()) { - val binding = bindings.getString(j) - if (binding.startsWith("GAMEPAD_")) { + val binding = BindingCombo.fromJsonValue(bindings.get(j)) + if (binding.isGamepadOnly) { hasGamepadBindings = true break } 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 7b7cbbe361..bc0e800a64 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 @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -130,25 +131,39 @@ fun DelayTextField( LaunchedEffect(clampedValue, value) { if (value != clampedValue) onValueChange(clampedValue) } + var text by remember { mutableStateOf(clampedValue.toString()) } + var isFocused by remember { mutableStateOf(false) } + + LaunchedEffect(clampedValue, isFocused) { + if (!isFocused) text = clampedValue.toString() + } NoExtractOutlinedTextField( - value = clampedValue.toString(), + value = text, onValueChange = { newText -> val filtered = newText.filter { it.isDigit() } - val nextValue = when { - filtered.isEmpty() -> valueRange.first - else -> filtered.toLongOrNull() - ?.coerceIn(valueRange.first.toLong(), valueRange.last.toLong()) - ?.toInt() - ?: valueRange.last + text = filtered + val parsed = filtered.toIntOrNull() + if (parsed != null && parsed in valueRange && parsed != value) { + onValueChange(parsed) } - onValueChange(nextValue) }, label = { Text(label) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier .fillMaxWidth() + .onFocusChanged { focusState -> + val wasFocused = isFocused + isFocused = focusState.isFocused + if (wasFocused && !focusState.isFocused) { + val nextValue = text.toIntOrNull() + ?.coerceIn(valueRange.first, valueRange.last) + ?: clampedValue + text = nextValue.toString() + if (nextValue != value) onValueChange(nextValue) + } + } .padding(horizontal = 12.dp, vertical = 0.dp), ) } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt index 5e57f21755..40ce717d44 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -465,7 +466,19 @@ private fun MouseHoldBehaviorPicker( } @Composable -private fun tapHoldActionLabel(action: String): String = when (action) { +private fun tapHoldActionLabel(action: String): String { + val parts = TouchGestureConfig.actionParts(action) + return if (parts.size > 1) { + val labels = mutableListOf() + for (part in parts) labels += tapHoldSingleActionLabel(part) + labels.joinToString(actionLabelSeparator(action)) + } else { + tapHoldSingleActionLabel(parts.firstOrNull() ?: action) + } +} + +@Composable +private fun tapHoldSingleActionLabel(action: String): String = when (action) { ACTION_LEFT_CLICK -> stringResource(R.string.gesture_action_left_click) ACTION_RIGHT_CLICK -> stringResource(R.string.gesture_action_right_click) ACTION_MIDDLE_CLICK -> stringResource(R.string.gesture_action_middle_click) @@ -524,11 +537,33 @@ private fun mouseBehaviorLabel(behavior: String): String = when (behavior) { } private fun isMouseButtonAction(action: String): Boolean { - return action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK + val primaryAction = TouchGestureConfig.primaryAction(action) + return primaryAction == ACTION_LEFT_CLICK || primaryAction == ACTION_RIGHT_CLICK || primaryAction == ACTION_MIDDLE_CLICK +} + +@Composable +private fun panActionLabel(action: String): String { + val parts = TouchGestureConfig.actionParts(action) + return if (parts.size > 1) { + val labels = mutableListOf() + for (part in parts) { + labels += if (PAN_ACTIONS.contains(part)) { + panSingleActionLabel(part) + } else { + tapHoldSingleActionLabel(part) + } + } + labels.joinToString(actionLabelSeparator(action)) + } else { + panSingleActionLabel(parts.firstOrNull() ?: action) + } } +private fun actionLabelSeparator(action: String): String = + if (TouchGestureConfig.isActionSequence(action)) " -> " else " + " + @Composable -private fun panActionLabel(action: String): String = when (action) { +private fun panSingleActionLabel(action: String): String = when (action) { PAN_MIDDLE_MOUSE -> stringResource(R.string.gesture_pan_middle_mouse) PAN_INVERTED_MIDDLE_MOUSE -> stringResource(R.string.gesture_pan_inverted_middle_mouse) PAN_WASD -> stringResource(R.string.gesture_pan_wasd) @@ -680,13 +715,14 @@ private fun TapHoldActionPicker( currentAction: String, onActionSelected: (String) -> Unit, ) { - CategorizedActionPicker( - currentValue = currentAction, + TouchActionComboPicker( + currentAction = currentAction, currentLabel = tapHoldActionLabel(currentAction), rowLabel = stringResource(R.string.gesture_action_label), dialogTitle = stringResource(R.string.gesture_action_label), categories = buildActionCategories(), - onValueSelected = onActionSelected, + actionLabel = { tapHoldSingleActionLabel(it) }, + onActionSelected = onActionSelected, ) } @@ -695,15 +731,14 @@ private fun MouseButtonActionPicker( currentAction: String, onActionSelected: (String) -> Unit, ) { - val actions = listOf(ACTION_LEFT_CLICK, ACTION_RIGHT_CLICK, ACTION_MIDDLE_CLICK) - SettingsListDropdown( - colors = settingsTileColors(), - title = { Text(stringResource(R.string.gesture_mouse_button_label)) }, - value = actions.indexOf(currentAction).coerceAtLeast(0), - items = actions.map { tapHoldActionLabel(it) }, - onItemSelected = { index -> - onActionSelected(actions[index]) - }, + TouchActionComboPicker( + currentAction = currentAction, + currentLabel = tapHoldActionLabel(currentAction), + rowLabel = stringResource(R.string.gesture_mouse_button_label), + dialogTitle = stringResource(R.string.gesture_mouse_button_label), + categories = buildMouseButtonActionCategories(), + actionLabel = { tapHoldSingleActionLabel(it) }, + onActionSelected = onActionSelected, ) } @@ -711,9 +746,76 @@ private fun MouseButtonActionPicker( private fun PanActionPicker( currentAction: String, onActionSelected: (String) -> Unit, +) { + TouchActionComboPicker( + currentAction = currentAction, + currentLabel = panActionLabel(currentAction), + rowLabel = stringResource(R.string.gesture_action_label), + dialogTitle = stringResource(R.string.gesture_action_label), + categories = buildPanActionCategories(), + allowSequence = false, + actionLabel = { action -> + if (PAN_ACTIONS.contains(action)) panSingleActionLabel(action) else tapHoldSingleActionLabel(action) + }, + onActionSelected = onActionSelected, + ) +} + +@Composable +private fun buildMouseButtonActionCategories(): List { + return listOf( + SettingsActionCategory( + header = stringResource(R.string.gesture_header_mouse), + actions = listOf( + ACTION_LEFT_CLICK to stringResource(R.string.gesture_action_left_click), + ACTION_RIGHT_CLICK to stringResource(R.string.gesture_action_right_click), + ACTION_MIDDLE_CLICK to stringResource(R.string.gesture_action_middle_click), + ), + ), + buildModifierActionCategory(), + ) +} + +@Composable +private fun buildPanActionCategories(): List { + val panActions = mutableListOf>() + for (action in PAN_ACTIONS) panActions += action to panSingleActionLabel(action) + return listOf( + SettingsActionCategory( + header = stringResource(R.string.gesture_header_mouse), + actions = panActions, + ), + buildModifierActionCategory(), + ) +} + +@Composable +private fun buildModifierActionCategory(): SettingsActionCategory { + return SettingsActionCategory( + header = stringResource(R.string.gesture_header_modifiers_locks), + actions = keyActionsOf( + Binding.KEY_SHIFT_L, + Binding.KEY_SHIFT_R, + Binding.KEY_CTRL_L, + Binding.KEY_CTRL_R, + Binding.KEY_ALT_L, + Binding.KEY_ALT_R, + ), + ) +} + +@Composable +private fun TouchActionComboPicker( + currentAction: String, + currentLabel: String, + rowLabel: String, + dialogTitle: String, + categories: List, + allowSequence: Boolean = true, + actionLabel: @Composable (String) -> String, + onActionSelected: (String) -> Unit, ) { var showDialog by remember { mutableStateOf(false) } - val label = panActionLabel(currentAction) Surface( modifier = Modifier @@ -733,9 +835,9 @@ private fun PanActionPicker( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(stringResource(R.string.gesture_action_label)) + Text(rowLabel) Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, color = MaterialTheme.colorScheme.primary) + Text(currentLabel, color = MaterialTheme.colorScheme.primary) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -746,33 +848,137 @@ private fun PanActionPicker( } if (showDialog) { + val selectedActions = remember(currentAction) { + mutableStateListOf().apply { + addAll(TouchGestureConfig.actionParts(currentAction)) + } + } + var selectedSequence by remember(currentAction) { + mutableStateOf(TouchGestureConfig.isActionSequence(currentAction)) + } + var selectedSequenceDelayMs by remember(currentAction) { + mutableIntStateOf(TouchGestureConfig.actionSequenceDelayMs(currentAction)) + } + val canChooseMode = allowSequence && selectedActions.size > 1 + AlertDialog( onDismissRequest = { showDialog = false }, containerColor = PluviaBackground, - title = { Text(stringResource(R.string.gesture_action_label)) }, + title = { Text(dialogTitle) }, text = { LazyColumn(modifier = Modifier.fillMaxWidth()) { - items(PAN_ACTIONS) { action -> - val isSelected = action == currentAction - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable { - onActionSelected(action) - showDialog = false - }, - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else PluviaSurface, - ) { + item { + val selectedLabels = mutableListOf() + for (action in selectedActions) selectedLabels += actionLabel(action) + Text( + text = selectedLabels + .takeIf { it.isNotEmpty() } + ?.joinToString(if (canChooseMode && selectedSequence) " -> " else " + ") + ?: stringResource(R.string.binding_none), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + if (canChooseMode) { + item { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + SegmentedButton( + selected = !selectedSequence, + onClick = { selectedSequence = false }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + label = { Text(stringResource(R.string.binding_mode_simultaneous)) }, + ) + SegmentedButton( + selected = selectedSequence, + onClick = { selectedSequence = true }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + label = { Text(stringResource(R.string.binding_mode_sequence)) }, + ) + } + } + if (selectedSequence) { + item { + DelayTextField( + label = stringResource(R.string.binding_sequence_delay_ms), + value = selectedSequenceDelayMs, + valueRange = TouchGestureConfig.MIN_ACTION_SEQUENCE_DELAY_MS.. + TouchGestureConfig.MAX_ACTION_SEQUENCE_DELAY_MS, + onValueChange = { selectedSequenceDelayMs = it }, + ) + } + } + } + categories.forEach { category -> + item { Text( - text = panActionLabel(action), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 10.dp), - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + text = category.header, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), ) } + items(category.actions) { (actionKey, actionText) -> + val isSelected = actionKey in selectedActions + val enabled = isSelected || selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { + if (isSelected) { + selectedActions.remove(actionKey) + } else if (selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE) { + selectedActions.add(actionKey) + } + }, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else PluviaSurface, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = actionText, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (enabled) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + if (isSelected) Icon(Icons.Default.Check, contentDescription = null) + } + } + } } } }, confirmButton = { + TextButton( + enabled = selectedActions.isNotEmpty(), + onClick = { + onActionSelected( + TouchGestureConfig.actionComboOf( + selectedActions, + sequence = canChooseMode && selectedSequence, + sequenceDelayMs = selectedSequenceDelayMs, + ) + ) + showDialog = false + }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { TextButton(onClick = { showDialog = false }) { Text(stringResource(android.R.string.cancel)) } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/InputControlsProfileCopy.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/InputControlsProfileCopy.kt index 905c63db21..4d25d72359 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/InputControlsProfileCopy.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/InputControlsProfileCopy.kt @@ -40,7 +40,7 @@ private fun ControlElement.copyForView(view: InputControlsView) = ControlElement } for (i in 0 until bindingCount) { - newElement.setBindingAt(i, getBindingAt(i)) + newElement.setBindingComboAt(i, getBindingComboAt(i)) } if (type == ControlElement.Type.SHOOTER_MODE) { 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 f5c5d22226..65afbe44d6 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 @@ -1,11 +1,14 @@ package app.gamenative.ui.screen.xserver import android.graphics.PointF +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlElement import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalController @@ -30,10 +33,12 @@ class PhysicalControllerHandler( companion object { private const val SCROLL_REPEAT_INTERVAL_MS = 90L private const val UNKNOWN_DEVICE_ID = -1 + private const val SEQUENCE_PRESS_MS = 80L } private val TAG = "gncontrol" private val mouseMoveOffset = PointF(0f, 0f) + private val sequenceHandler = Handler(Looper.getMainLooper()) private var mouseMoveTimer: Timer? = null private var scrollRepeatTimer: Timer? = null private val scrollRepeatLock = Any() @@ -41,6 +46,8 @@ class PhysicalControllerHandler( // track which axis keycodes are currently "pressed" so we only release on actual transitions. // accessed only from main thread (MotionEvent dispatch + Compose lifecycle), no sync needed. private val activeAxisBindings = mutableSetOf() + private val activeSequenceTriggerBindings = mutableSetOf() + private val activeSequenceBindings = mutableSetOf() // Tracks whether SHOW_KEYBOARD is currently held, so onShowKeyboard fires once per press (rising edge only) private var showKeyboardPressed = false @@ -54,16 +61,27 @@ class PhysicalControllerHandler( for (keyCode in activeAxisBindings.toList()) { if (keyCode == exceptKeyCode) continue activeAxisBindings.remove(keyCode) - controller.getControllerBinding(keyCode)?.takeIf { it.binding != Binding.OPEN_RADIAL_MENU }?.let { - handleInputEvent(it.binding, false, 0f) + controller.getControllerBinding(keyCode) + ?.takeIf { Binding.OPEN_RADIAL_MENU !in it.bindingCombo.bindings } + ?.let { + handleInputEvent( + it.bindingCombo, + false, + 0f, + fromMotion = true, + sourceKeyCode = keyCode, + sourceController = controller, + ) + } } - } } fun setProfile(profile: ControlsProfile?) { releaseActiveAxes() + cancelActiveSequences() clearScrollRepeats() closeRadialMenuIfOpen(commit = false) + activeSequenceTriggerBindings.clear() this.profile = profile Log.d(TAG, "PhysicalControllerHandler: Profile set to ${profile?.name}") @@ -80,10 +98,12 @@ class PhysicalControllerHandler( */ fun cleanup() { releaseActiveAxes() + cancelActiveSequences() mouseMoveTimer?.cancel() mouseMoveTimer = null mouseMoveOffset.set(0f, 0f) clearScrollRepeats() + activeSequenceTriggerBindings.clear() showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) } @@ -98,12 +118,12 @@ class PhysicalControllerHandler( val controller = profile?.getController(event.deviceId) if (controller != null) { val controllerBinding = controller.getControllerBinding(event.keyCode) - if (radialMenuPressed && controllerBinding?.binding == Binding.OPEN_RADIAL_MENU) { + if (radialMenuPressed && controllerBinding?.bindingCombo?.bindings?.contains(Binding.OPEN_RADIAL_MENU) == true) { if (event.keyCode == radialMenuOpenerKeyCode || radialMenuOpenerKeyCode == KeyEvent.KEYCODE_UNKNOWN ) { handleInputEvent( - controllerBinding.binding, + controllerBinding.bindingCombo, event.action == KeyEvent.ACTION_DOWN, sourceKeyCode = event.keyCode, sourceDeviceId = event.deviceId, @@ -123,16 +143,16 @@ class PhysicalControllerHandler( // If this physical key is mapped to a virtual trigger AND the device exposes trigger axes, // ignore the KeyEvent to avoid an initial "full press" spike. MotionEvent will provide the analog value. if ((event.keyCode == KeyEvent.KEYCODE_BUTTON_L2 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R2) && - (controllerBinding.binding == Binding.GAMEPAD_BUTTON_L2 || controllerBinding.binding == Binding.GAMEPAD_BUTTON_R2) && + controllerBinding.bindingCombo.bindings.any { it == Binding.GAMEPAD_BUTTON_L2 || it == Binding.GAMEPAD_BUTTON_R2 } && deviceHasTriggerAxis(event.device, event.keyCode) ) { return true } val offset = if (event.action == KeyEvent.ACTION_DOWN && - (controllerBinding.binding == Binding.GAMEPAD_BUTTON_L2 || controllerBinding.binding == Binding.GAMEPAD_BUTTON_R2) + controllerBinding.bindingCombo.bindings.any { it == Binding.GAMEPAD_BUTTON_L2 || it == Binding.GAMEPAD_BUTTON_R2 } ) 1f else 0f handleInputEvent( - controllerBinding.binding, + controllerBinding.bindingCombo, event.action == KeyEvent.ACTION_DOWN, offset, sourceKeyCode = event.keyCode, @@ -193,8 +213,10 @@ class PhysicalControllerHandler( // Process trigger buttons (L2/R2) var controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2) if (controllerBinding != null) { - handleInputEvent( + handleTriggerBinding( + KeyEvent.KEYCODE_BUTTON_L2, controllerBinding.binding, + controllerBinding.bindingCombo, controller.state.triggerL > 0f, controller.state.triggerL, fromMotion = true, @@ -210,8 +232,10 @@ class PhysicalControllerHandler( controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_R2) if (controllerBinding != null) { - handleInputEvent( + handleTriggerBinding( + KeyEvent.KEYCODE_BUTTON_R2, controllerBinding.binding, + controllerBinding.bindingCombo, controller.state.triggerR > 0f, controller.state.triggerR, fromMotion = true, @@ -352,25 +376,26 @@ class PhysicalControllerHandler( val activeKey = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) val oppositeKey = if (activeKey == posKeyCode) negKeyCode else posKeyCode - // always send press (gamepad bindings need continuous offset updates) - activeAxisBindings.add(activeKey) + val wasAlreadyActive = !activeAxisBindings.add(activeKey) controller.getControllerBinding(activeKey)?.let { - handleInputEvent( - it.binding, - true, - values[i], - fromMotion = true, - sourceKeyCode = activeKey, - sourceDeviceId = deviceId, - sourceController = controller, - ) + if (!it.bindingCombo.isSequence || !wasAlreadyActive) { + handleInputEvent( + it.bindingCombo, + true, + values[i], + fromMotion = true, + sourceKeyCode = activeKey, + sourceDeviceId = deviceId, + sourceController = controller, + ) + } if (radialMenuPressed) return } // release opposite direction (if it was active) if (activeAxisBindings.remove(oppositeKey)) { controller.getControllerBinding(oppositeKey)?.let { handleInputEvent( - it.binding, + it.bindingCombo, false, 0f, fromMotion = true, @@ -385,7 +410,7 @@ class PhysicalControllerHandler( if (activeAxisBindings.remove(posKeyCode)) { controller.getControllerBinding(posKeyCode)?.let { handleInputEvent( - it.binding, + it.bindingCombo, false, 0f, fromMotion = true, @@ -398,7 +423,7 @@ class PhysicalControllerHandler( if (activeAxisBindings.remove(negKeyCode)) { controller.getControllerBinding(negKeyCode)?.let { handleInputEvent( - it.binding, + it.bindingCombo, false, 0f, fromMotion = true, @@ -412,12 +437,151 @@ class PhysicalControllerHandler( } } + private fun handleTriggerBinding( + keyCode: Int, + legacyBinding: Binding, + bindingCombo: BindingCombo, + isPressed: Boolean, + offset: Float, + fromMotion: Boolean = false, + sourceKeyCode: Int = KeyEvent.KEYCODE_UNKNOWN, + sourceDeviceId: Int = UNKNOWN_DEVICE_ID, + sourceController: ExternalController? = null, + ) { + if (bindingCombo.isSequence) { + if (isPressed) { + if (activeSequenceTriggerBindings.add(keyCode)) { + handleInputEvent( + bindingCombo, + true, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + } + } else { + activeSequenceTriggerBindings.remove(keyCode) + } + } else { + handleInputEvent( + bindingCombo.takeIf { !it.isEmpty } ?: BindingCombo.of(legacyBinding), + isPressed, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + } + } + /** * Apply a binding to the virtual gamepad state and send to WinHandler. * Extracted from InputControlsView.handleInputEvent() */ // offset: analog axis value for presses; must be 0f for releases (triggers use offset > 0f // to determine pressed state, sticks gate on isActionDown, everything else ignores offset) + private fun handleInputEvent( + bindingCombo: BindingCombo, + isActionDown: Boolean, + offset: Float = 0f, + fromMotion: Boolean = false, + sourceKeyCode: Int = KeyEvent.KEYCODE_UNKNOWN, + sourceDeviceId: Int = UNKNOWN_DEVICE_ID, + sourceController: ExternalController? = null, + ) { + if (bindingCombo.isEmpty) return + if (bindingCombo.isSequence) { + if (isActionDown) { + performBindingSequence( + bindingCombo, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + } + return + } + + if (isActionDown) { + bindingCombo.bindings.forEach { binding -> + handleInputEvent( + binding, + true, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + } + } else { + bindingCombo.bindings.asReversed().forEach { binding -> + handleInputEvent( + binding, + false, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + } + } + } + + private fun performBindingSequence( + bindingCombo: BindingCombo, + offset: Float, + fromMotion: Boolean, + sourceKeyCode: Int, + sourceDeviceId: Int, + sourceController: ExternalController?, + ) { + bindingCombo.bindings.forEachIndexed { index, binding -> + sequenceHandler.postDelayed({ + handleInputEvent( + binding, + true, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + activeSequenceBindings.add(binding) + sendGamepadState() + sequenceHandler.postDelayed({ + handleInputEvent( + binding, + false, + 0f, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + activeSequenceBindings.remove(binding) + sendGamepadState() + }, SEQUENCE_PRESS_MS) + }, index * bindingCombo.sequenceDelayMs.toLong()) + } + } + + private fun cancelActiveSequences() { + sequenceHandler.removeCallbacksAndMessages(null) + if (activeSequenceBindings.isEmpty()) return + activeSequenceBindings.toList().asReversed().forEach { binding -> + handleInputEvent(binding, false, 0f) + } + activeSequenceBindings.clear() + sendGamepadState() + } + private fun handleInputEvent( binding: Binding, isActionDown: Boolean, @@ -460,12 +624,14 @@ class PhysicalControllerHandler( if (buttonIdx <= ExternalController.IDX_BUTTON_R2.toInt()) { when (buttonIdx) { ExternalController.IDX_BUTTON_L2.toInt() -> { - state.triggerL = offset - state.setPressed(ExternalController.IDX_BUTTON_L2.toInt(), offset > 0f) + val triggerValue = if (offset > 0f) offset else if (isActionDown) 1f else 0f + state.triggerL = triggerValue + state.setPressed(ExternalController.IDX_BUTTON_L2.toInt(), triggerValue > 0f) } ExternalController.IDX_BUTTON_R2.toInt() -> { - state.triggerR = offset - state.setPressed(ExternalController.IDX_BUTTON_R2.toInt(), offset > 0f) + val triggerValue = if (offset > 0f) offset else if (isActionDown) 1f else 0f + state.triggerR = triggerValue + state.setPressed(ExternalController.IDX_BUTTON_R2.toInt(), triggerValue > 0f) } else -> state.setPressed(buttonIdx, isActionDown) } @@ -601,9 +767,18 @@ class PhysicalControllerHandler( else -> 0f } if (triggerValue <= 0f) return - activeController.getControllerBinding(keyCode)?.takeIf { it.binding != Binding.OPEN_RADIAL_MENU }?.let { - handleInputEvent(it.binding, false, 0f, fromMotion = true, sourceKeyCode = keyCode) - } + activeController.getControllerBinding(keyCode) + ?.takeIf { Binding.OPEN_RADIAL_MENU !in it.bindingCombo.bindings } + ?.let { + handleInputEvent( + it.bindingCombo, + false, + 0f, + fromMotion = true, + sourceKeyCode = keyCode, + sourceController = activeController, + ) + } } private fun isRadialMenuOpenerDevice(deviceId: Int): Boolean { diff --git a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java new file mode 100644 index 0000000000..bdcb3cf15c --- /dev/null +++ b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java @@ -0,0 +1,227 @@ +package com.winlator.inputcontrols; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +public final class BindingCombo { + public static final int MAX_BINDINGS = 3; + public static final int DEFAULT_SEQUENCE_DELAY_MS = 150; + public static final int MIN_SEQUENCE_DELAY_MS = 80; + public static final int MAX_SEQUENCE_DELAY_MS = 1000; + private static final BindingCombo NONE = new BindingCombo(Collections.emptyList(), Mode.SIMULTANEOUS, DEFAULT_SEQUENCE_DELAY_MS); + + private final List bindings; + private final Mode mode; + private final int sequenceDelayMs; + + public enum Mode { + SIMULTANEOUS("simultaneous"), + SEQUENCE("sequence"); + + private final String jsonName; + + Mode(String jsonName) { + this.jsonName = jsonName; + } + + public String getJsonName() { + return jsonName; + } + + public static Mode fromJsonName(String value) { + return SEQUENCE.jsonName.equals(value) ? SEQUENCE : SIMULTANEOUS; + } + } + + private BindingCombo(List bindings, Mode mode, int sequenceDelayMs) { + this.bindings = Collections.unmodifiableList(bindings); + this.mode = bindings.size() > 1 && mode != null ? mode : Mode.SIMULTANEOUS; + this.sequenceDelayMs = normalizeSequenceDelayMs(sequenceDelayMs); + } + + public static BindingCombo none() { + return NONE; + } + + public static BindingCombo of(Binding binding) { + if (binding == null || binding == Binding.NONE) return NONE; + ArrayList bindings = new ArrayList<>(1); + bindings.add(binding); + return new BindingCombo(bindings, Mode.SIMULTANEOUS, DEFAULT_SEQUENCE_DELAY_MS); + } + + public static BindingCombo fromBindings(List values) { + return fromBindings(values, Mode.SIMULTANEOUS); + } + + public static BindingCombo fromBindings(List values, Mode mode) { + return fromBindings(values, mode, DEFAULT_SEQUENCE_DELAY_MS); + } + + public static BindingCombo fromBindings(List values, Mode mode, int sequenceDelayMs) { + if (values == null || values.isEmpty()) return NONE; + + ArrayList normalized = new ArrayList<>(); + HashSet seen = new HashSet<>(); + for (Binding binding : values) { + if (binding == null || binding == Binding.NONE || seen.contains(binding)) continue; + normalized.add(binding); + seen.add(binding); + if (normalized.size() == MAX_BINDINGS) break; + } + if (normalized.isEmpty()) return NONE; + if (mode != Mode.SEQUENCE) normalized.sort(Comparator.comparingInt(BindingCombo::sortGroup)); + return new BindingCombo(normalized, mode, sequenceDelayMs); + } + + public static BindingCombo fromJsonValue(Object value) { + if (value instanceof JSONObject) { + JSONObject object = (JSONObject)value; + return fromJsonArray( + object.optJSONArray("bindings"), + Mode.fromJsonName(object.optString("mode", Mode.SIMULTANEOUS.getJsonName())), + object.optInt("sequenceDelayMs", object.optInt("delayMs", DEFAULT_SEQUENCE_DELAY_MS)) + ); + } + if (value instanceof JSONArray) { + return fromJsonArray((JSONArray)value); + } + if (value instanceof String) { + return of(Binding.fromString((String)value)); + } + return NONE; + } + + public static BindingCombo fromJsonArray(JSONArray array) { + return fromJsonArray(array, Mode.SIMULTANEOUS); + } + + public static BindingCombo fromJsonArray(JSONArray array, Mode mode) { + return fromJsonArray(array, mode, DEFAULT_SEQUENCE_DELAY_MS); + } + + public static BindingCombo fromJsonArray(JSONArray array, Mode mode, int sequenceDelayMs) { + if (array == null) return NONE; + ArrayList values = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + Object value = array.opt(i); + if (value instanceof String) values.add(Binding.fromString((String)value)); + } + return fromBindings(values, mode, sequenceDelayMs); + } + + private static int normalizeSequenceDelayMs(int value) { + return Math.max(MIN_SEQUENCE_DELAY_MS, Math.min(MAX_SEQUENCE_DELAY_MS, value)); + } + + private static int sortGroup(Binding binding) { + switch (binding) { + case KEY_CTRL_L: + case KEY_CTRL_R: + case KEY_SHIFT_L: + case KEY_SHIFT_R: + case KEY_ALT_L: + case KEY_ALT_R: + return 0; + default: + return 1; + } + } + + public boolean isEmpty() { + return bindings.isEmpty(); + } + + public boolean isSingleBinding() { + return bindings.size() <= 1; + } + + public boolean isSequence() { + return mode == Mode.SEQUENCE; + } + + public Mode getMode() { + return mode; + } + + public int getSequenceDelayMs() { + return sequenceDelayMs; + } + + public int size() { + return bindings.size(); + } + + public Binding getPrimaryBinding() { + return bindings.isEmpty() ? Binding.NONE : bindings.get(bindings.size() - 1); + } + + public List getBindings() { + return bindings; + } + + public boolean isGamepadOnly() { + if (bindings.isEmpty()) return false; + for (Binding binding : bindings) if (!binding.isGamepad()) return false; + return true; + } + + public Object toJsonValue() { + if (bindings.size() <= 1) return getPrimaryBinding().name(); + if (mode == Mode.SEQUENCE) { + try { + JSONObject object = new JSONObject(); + object.put("mode", mode.getJsonName()); + object.put("sequenceDelayMs", sequenceDelayMs); + object.put("bindings", toJsonArray()); + return object; + } catch (JSONException e) { + return toJsonArray(); + } + } + return toJsonArray(); + } + + public JSONArray toJsonArray() { + JSONArray array = new JSONArray(); + for (Binding binding : bindings) array.put(binding.name()); + return array; + } + + @NonNull + @Override + public String toString() { + if (bindings.isEmpty()) return Binding.NONE.toString(); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < bindings.size(); i++) { + if (i > 0) builder.append(mode == Mode.SEQUENCE ? " -> " : " + "); + builder.append(bindings.get(i).toString()); + } + return builder.toString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof BindingCombo)) return false; + BindingCombo other = (BindingCombo)obj; + return sequenceDelayMs == other.sequenceDelayMs + && mode == other.mode + && bindings.equals(other.bindings); + } + + @Override + public int hashCode() { + return Objects.hash(bindings, mode, sequenceDelayMs); + } +} diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index 0e63de3024..2650671da4 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -74,7 +74,7 @@ public static String[] names() { private final InputControlsView inputControlsView; private Type type = Type.BUTTON; private Shape shape = Shape.CIRCLE; - private Binding[] bindings = {Binding.NONE, Binding.NONE, Binding.NONE, Binding.NONE}; + private BindingCombo[] bindings = {BindingCombo.none(), BindingCombo.none(), BindingCombo.none(), BindingCombo.none()}; private float scale = 1.0f; private short x; private short y; @@ -119,22 +119,22 @@ private void reset() { lookThrough = DEFAULT_LOOK_THROUGH; if (type == Type.STICK) { - bindings[0] = Binding.GAMEPAD_LEFT_THUMB_UP; - bindings[1] = Binding.GAMEPAD_LEFT_THUMB_RIGHT; - bindings[2] = Binding.GAMEPAD_LEFT_THUMB_DOWN; - bindings[3] = Binding.GAMEPAD_LEFT_THUMB_LEFT; + bindings[0] = BindingCombo.of(Binding.GAMEPAD_LEFT_THUMB_UP); + bindings[1] = BindingCombo.of(Binding.GAMEPAD_LEFT_THUMB_RIGHT); + bindings[2] = BindingCombo.of(Binding.GAMEPAD_LEFT_THUMB_DOWN); + bindings[3] = BindingCombo.of(Binding.GAMEPAD_LEFT_THUMB_LEFT); } else if (type == Type.D_PAD) { - bindings[0] = Binding.GAMEPAD_DPAD_UP; - bindings[1] = Binding.GAMEPAD_DPAD_RIGHT; - bindings[2] = Binding.GAMEPAD_DPAD_DOWN; - bindings[3] = Binding.GAMEPAD_DPAD_LEFT; + bindings[0] = BindingCombo.of(Binding.GAMEPAD_DPAD_UP); + bindings[1] = BindingCombo.of(Binding.GAMEPAD_DPAD_RIGHT); + bindings[2] = BindingCombo.of(Binding.GAMEPAD_DPAD_DOWN); + bindings[3] = BindingCombo.of(Binding.GAMEPAD_DPAD_LEFT); } else if (type == Type.TRACKPAD) { - bindings[0] = Binding.MOUSE_MOVE_UP; - bindings[1] = Binding.MOUSE_MOVE_RIGHT; - bindings[2] = Binding.MOUSE_MOVE_DOWN; - bindings[3] = Binding.MOUSE_MOVE_LEFT; + bindings[0] = BindingCombo.of(Binding.MOUSE_MOVE_UP); + bindings[1] = BindingCombo.of(Binding.MOUSE_MOVE_RIGHT); + bindings[2] = BindingCombo.of(Binding.MOUSE_MOVE_DOWN); + bindings[3] = BindingCombo.of(Binding.MOUSE_MOVE_LEFT); } else if (type == Type.RANGE_BUTTON) { scroller = new RangeScroller(inputControlsView, this); @@ -176,7 +176,7 @@ public int getBindingCount() { } public void setBindingCount(int bindingCount) { - bindings = new Binding[bindingCount]; + bindings = new BindingCombo[bindingCount]; setBinding(Binding.NONE); states = new boolean[bindingCount]; boundingBoxNeedsUpdate = true; @@ -225,22 +225,30 @@ public void setScrollLocked(boolean scrollLocked) { } public Binding getBindingAt(int index) { - return index < bindings.length ? bindings[index] : Binding.NONE; + return getBindingComboAt(index).getPrimaryBinding(); + } + + public BindingCombo getBindingComboAt(int index) { + return index < bindings.length ? bindings[index] : BindingCombo.none(); } public void setBindingAt(int index, Binding binding) { + setBindingComboAt(index, BindingCombo.of(binding)); + } + + public void setBindingComboAt(int index, BindingCombo binding) { if (index >= bindings.length) { int oldLength = bindings.length; bindings = Arrays.copyOf(bindings, index+1); - Arrays.fill(bindings, oldLength, bindings.length, Binding.NONE); + Arrays.fill(bindings, oldLength, bindings.length, BindingCombo.none()); states = new boolean[bindings.length]; boundingBoxNeedsUpdate = true; } - bindings[index] = binding; + bindings[index] = binding != null ? binding : BindingCombo.none(); } public void setBinding(Binding binding) { - Arrays.fill(bindings, binding); + Arrays.fill(bindings, BindingCombo.of(binding)); } public String getShooterMovementType() { @@ -500,8 +508,9 @@ private String getDisplayText() { return text; } else { - Binding binding = getBindingAt(0); - String text = binding.toString().replace("NUMPAD ", "NP").replace("BUTTON ", "").replace("SHOW KEYBOARD", "KEY"); + BindingCombo bindingCombo = getBindingComboAt(0); + Binding binding = bindingCombo.getPrimaryBinding(); + String text = bindingCombo.toString().replace("NUMPAD ", "NP").replace("BUTTON ", "").replace("SHOW KEYBOARD", "KEY"); if (text.length() > 7) { String[] parts = text.split(" "); StringBuilder sb = new StringBuilder(); @@ -1042,7 +1051,7 @@ public JSONObject toJSONObject() { elementJSONObject.put("shape", shape.name()); JSONArray bindingsJSONArray = new JSONArray(); - for (Binding binding : bindings) bindingsJSONArray.put(binding.name()); + for (BindingCombo binding : bindings) bindingsJSONArray.put(binding.toJsonValue()); elementJSONObject.put("bindings", bindingsJSONArray); elementJSONObject.put("scale", Float.valueOf(scale)); @@ -1100,7 +1109,7 @@ private boolean handleRadialMenuDirectionalMove(int pointerId, boolean[] directi } for (byte i = 0; i < directionalStates.length && i < bindings.length; i++) { - if (bindings[i] == Binding.OPEN_RADIAL_MENU && directionalStates[i]) { + if (getBindingAt(i) == Binding.OPEN_RADIAL_MENU && directionalStates[i]) { releaseActiveDirectionalStates(); radialMenuTouchActive = true; inputControlsView.handleRadialMenuTouchDown(pointerId, x, y); @@ -1114,7 +1123,7 @@ private boolean handleRadialMenuDirectionalMove(int pointerId, boolean[] directi private void releaseActiveDirectionalStates() { for (byte i = 0; i < states.length && i < bindings.length; i++) { - if (states[i]) inputControlsView.handleInputEvent(getBindingAt(i), false); + if (states[i]) inputControlsView.handleInputEvent(getBindingComboAt(i), false); states[i] = false; } } @@ -1133,8 +1142,8 @@ public boolean handleTouchDown(int pointerId, float x, float y) { if (isKeepButtonPressedAfterMinTime()) touchTime = System.currentTimeMillis(); if (!toggleSwitch || !selected) { currentPointerActivatedButtonBindings = !selected; - inputControlsView.handleInputEvent(getBindingAt(0), true); - inputControlsView.handleInputEvent(getBindingAt(1), true); + inputControlsView.handleInputEvent(getBindingComboAt(0), true); + inputControlsView.handleInputEvent(getBindingComboAt(1), true); } inputControlsView.invalidate(); return true; @@ -1210,12 +1219,12 @@ public boolean handleTouchMove(int pointerId, float x, float y) { Binding binding = getBindingAt(i); if (binding.isGamepad()) { value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * STICK_SENSITIVITY, -1, 1); - inputControlsView.handleInputEvent(binding, true, value); + inputControlsView.handleInputEvent(getBindingComboAt(i), true, value); this.states[i] = true; } else { boolean state = binding.isMouseMove() ? (states[i] || states[(i+2)%4]) : states[i]; - inputControlsView.handleInputEvent(binding, state, value); + inputControlsView.handleInputEvent(getBindingComboAt(i), state, value); this.states[i] = state; } } @@ -1236,7 +1245,7 @@ else if (type == Type.TRACKPAD) { if (Math.abs(value) > TRACKPAD_ACCELERATION_THRESHOLD) value *= STICK_SENSITIVITY; interpolator.set(0.075f, 0.95f, 0.45f, 0.95f); float interpolatedValue = interpolator.getInterpolation(Math.min(1.0f, Math.abs(value / TRACKPAD_MAX_SPEED))); - inputControlsView.handleInputEvent(binding, true, Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1)); + inputControlsView.handleInputEvent(getBindingComboAt(i), true, Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1)); this.states[i] = true; } else { @@ -1248,7 +1257,7 @@ else if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) cursorDy = Mathf.roundPoint(value); } else { - inputControlsView.handleInputEvent(binding, states[i], value); + inputControlsView.handleInputEvent(getBindingComboAt(i), states[i], value); this.states[i] = states[i]; } } @@ -1266,13 +1275,13 @@ else if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; if (this.states[i] && !states[i]) { - inputControlsView.handleInputEvent(getBindingAt(i), false, value); + inputControlsView.handleInputEvent(getBindingComboAt(i), false, value); } } for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; - if (states[i]) inputControlsView.handleInputEvent(getBindingAt(i), true, value); + if (states[i]) inputControlsView.handleInputEvent(getBindingComboAt(i), true, value); this.states[i] = states[i]; } @@ -1310,15 +1319,15 @@ public boolean handleTouchUp(int pointerId) { else if (isKeepButtonPressedAfterMinTime() && touchTime != null) { selected = (System.currentTimeMillis() - (long)touchTime) > BUTTON_MIN_TIME_TO_KEEP_PRESSED; if (!selected) { - inputControlsView.handleInputEvent(getBindingAt(0), false); - inputControlsView.handleInputEvent(getBindingAt(1), false); + inputControlsView.handleInputEvent(getBindingComboAt(0), false); + inputControlsView.handleInputEvent(getBindingComboAt(1), false); } touchTime = null; inputControlsView.invalidate(); } else if (!toggleSwitch || selected) { - inputControlsView.handleInputEvent(getBindingAt(0), false); - inputControlsView.handleInputEvent(getBindingAt(1), false); + inputControlsView.handleInputEvent(getBindingComboAt(0), false); + inputControlsView.handleInputEvent(getBindingComboAt(1), false); } if (toggleSwitch) { @@ -1332,7 +1341,7 @@ else if (!toggleSwitch || selected) { } else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { for (byte i = 0; i < states.length; i++) { - if (states[i]) inputControlsView.handleInputEvent(getBindingAt(i), false); + if (states[i]) inputControlsView.handleInputEvent(getBindingComboAt(i), false); states[i] = false; } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index d8b1af3c85..83c61386eb 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -283,7 +283,20 @@ public ArrayList loadControllers() { JSONObject controllerBindingJSONObject = controllerBindingsJSONArray.getJSONObject(j); ExternalControllerBinding controllerBinding = new ExternalControllerBinding(); controllerBinding.setKeyCode(controllerBindingJSONObject.getInt("keyCode")); - controllerBinding.setBinding(Binding.fromString(controllerBindingJSONObject.getString("binding"))); + if (controllerBindingJSONObject.has("bindings")) { + BindingCombo.Mode mode = BindingCombo.Mode.fromJsonName( + controllerBindingJSONObject.optString("bindingMode", BindingCombo.Mode.SIMULTANEOUS.getJsonName())); + int delayMs = controllerBindingJSONObject.optInt( + "bindingDelayMs", + BindingCombo.DEFAULT_SEQUENCE_DELAY_MS); + controllerBinding.setBindingCombo(BindingCombo.fromJsonArray( + controllerBindingJSONObject.optJSONArray("bindings"), + mode, + delayMs)); + } + else { + controllerBinding.setBinding(Binding.fromString(controllerBindingJSONObject.getString("binding"))); + } controller.addControllerBinding(controllerBinding); } controllers.add(controller); @@ -365,9 +378,9 @@ public void loadElements(InputControlsView inputControlsView) { JSONArray bindingsJSONArray = elementJSONObject.getJSONArray("bindings"); element.setBindingCount(Math.max(bindingsJSONArray.length(), 4)); for (int j = 0; j < bindingsJSONArray.length(); j++) { - Binding binding = Binding.fromString(bindingsJSONArray.getString(j)); - element.setBindingAt(j, binding); - if (!binding.isGamepad()) hasGamepadBinding = false; + BindingCombo binding = BindingCombo.fromJsonValue(bindingsJSONArray.get(j)); + element.setBindingComboAt(j, binding); + if (!binding.isGamepadOnly()) hasGamepadBinding = false; } if (!virtualGamepad && hasGamepadBinding) virtualGamepad = true; diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java b/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java index 5e1b8d587f..dc8fefa695 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java @@ -18,7 +18,7 @@ public class ExternalControllerBinding { public static final byte AXIS_RZ_NEGATIVE = -7; public static final byte AXIS_RZ_POSITIVE = -8; private short keyCode; - private Binding binding = Binding.NONE; + private BindingCombo bindingCombo = BindingCombo.none(); public int getKeyCodeForAxis() { return this.keyCode; @@ -29,18 +29,33 @@ public void setKeyCode(int keyCode) { } public Binding getBinding() { - return this.binding; + return this.bindingCombo.getPrimaryBinding(); } public void setBinding(Binding binding) { - this.binding = binding; + this.bindingCombo = BindingCombo.of(binding); + } + + public BindingCombo getBindingCombo() { + return this.bindingCombo; + } + + public void setBindingCombo(BindingCombo bindingCombo) { + this.bindingCombo = bindingCombo != null ? bindingCombo : BindingCombo.none(); } public JSONObject toJSONObject() { try { JSONObject controllerBindingJSONObject = new JSONObject(); controllerBindingJSONObject.put("keyCode", (int) this.keyCode); - controllerBindingJSONObject.put("binding", this.binding.name()); + controllerBindingJSONObject.put("binding", getBinding().name()); + if (!bindingCombo.isSingleBinding()) { + controllerBindingJSONObject.put("bindings", bindingCombo.toJsonArray()); + if (bindingCombo.isSequence()) { + controllerBindingJSONObject.put("bindingMode", bindingCombo.getMode().getJsonName()); + controllerBindingJSONObject.put("bindingDelayMs", bindingCombo.getSequenceDelayMs()); + } + } return controllerBindingJSONObject; } catch (JSONException e) { return null; diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 765a9de8bc..cc70078c8e 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -30,6 +30,7 @@ import app.gamenative.R; import app.gamenative.data.ShooterModeConfig; import com.winlator.inputcontrols.Binding; +import com.winlator.inputcontrols.BindingCombo; import com.winlator.inputcontrols.ControlElement; import com.winlator.inputcontrols.ControlsProfile; import com.winlator.inputcontrols.ExternalController; @@ -44,12 +45,16 @@ import java.io.IOException; import java.io.InputStream; import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; import java.util.Timer; import java.util.TimerTask; public class InputControlsView extends View { private static final long SHOOTER_SPRINT_TAP_DURATION_MS = 120; public static final float DEFAULT_OVERLAY_OPACITY = 0.4f; + private static final int SEQUENCE_PRESS_MS = 80; private boolean editMode = false; private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); @@ -57,6 +62,8 @@ public class InputControlsView extends View { private final Point cursor = new Point(); private boolean readyToDraw = false; private boolean moveCursor = false; + private final Set activeSequenceCombos = Collections.newSetFromMap(new IdentityHashMap<>()); + private int sequenceGeneration = 0; private int snappingSize; private float offsetX; private float offsetY; @@ -333,6 +340,8 @@ public synchronized ControlsProfile getProfile() { public synchronized void setProfile(ControlsProfile profile) { cancelTouchRouting(); + sequenceGeneration++; + activeSequenceCombos.clear(); if (profile != null) { this.profile = profile; deselectAllElements(); @@ -431,13 +440,13 @@ private void processJoystickInput(ExternalController controller) { for (byte i = 0; i < axes.length; i++) { if (Math.abs(values[i]) > ControlElement.STICK_DEAD_ZONE) { controllerBinding = controller.getControllerBinding(ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i]))); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), true, values[i]); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBindingCombo(), true, values[i]); } else { controllerBinding = controller.getControllerBinding(ExternalControllerBinding.getKeyCodeForAxis(axes[i], (byte) 1)); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), false, values[i]); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBindingCombo(), false, values[i]); controllerBinding = controller.getControllerBinding(ExternalControllerBinding.getKeyCodeForAxis(axes[i], (byte)-1)); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), false, values[i]); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBindingCombo(), false, values[i]); } } } @@ -1196,10 +1205,10 @@ public boolean onGenericMotionEvent(MotionEvent event) { if (controller != null && controller.updateStateFromMotionEvent(event)) { ExternalControllerBinding controllerBinding; controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), controller.state.isPressed(ExternalController.IDX_BUTTON_L2)); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBindingCombo(), controller.state.isPressed(ExternalController.IDX_BUTTON_L2)); controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_R2); - if (controllerBinding != null) handleInputEvent(controllerBinding.getBinding(), controller.state.isPressed(ExternalController.IDX_BUTTON_R2)); + if (controllerBinding != null) handleInputEvent(controllerBinding.getBindingCombo(), controller.state.isPressed(ExternalController.IDX_BUTTON_R2)); processJoystickInput(controller); return true; @@ -1396,10 +1405,10 @@ public boolean onKeyEvent(KeyEvent event) { if (controllerBinding != null) { int action = event.getAction(); if (action == KeyEvent.ACTION_DOWN) { - handleInputEvent(controllerBinding.getBinding(), true); + handleInputEvent(controllerBinding.getBindingCombo(), true); } else if (action == KeyEvent.ACTION_UP) { - handleInputEvent(controllerBinding.getBinding(), false); + handleInputEvent(controllerBinding.getBindingCombo(), false); } return true; } @@ -1412,6 +1421,48 @@ public void handleInputEvent(Binding binding, boolean isActionDown) { handleInputEvent(binding, isActionDown, 0); } + public void handleInputEvent(BindingCombo bindingCombo, boolean isActionDown) { + handleInputEvent(bindingCombo, isActionDown, 0); + } + + public void handleInputEvent(BindingCombo bindingCombo, boolean isActionDown, float offset) { + if (bindingCombo == null || bindingCombo.isEmpty()) return; + if (bindingCombo.isSequence()) { + if (isActionDown) { + if (activeSequenceCombos.add(bindingCombo)) performBindingSequence(bindingCombo, offset); + } + else { + activeSequenceCombos.remove(bindingCombo); + } + return; + } + + if (isActionDown) { + for (Binding binding : bindingCombo.getBindings()) { + handleInputEvent(binding, true, offset); + } + } + else { + java.util.List bindings = bindingCombo.getBindings(); + for (int i = bindings.size() - 1; i >= 0; i--) { + handleInputEvent(bindings.get(i), false, offset); + } + } + } + + private void performBindingSequence(BindingCombo bindingCombo, float offset) { + final int generation = sequenceGeneration; + long delay = 0; + for (Binding binding : bindingCombo.getBindings()) { + postDelayed(() -> { + if (generation != sequenceGeneration) return; + handleInputEvent(binding, true, offset); + postDelayed(() -> handleInputEvent(binding, false, 0), SEQUENCE_PRESS_MS); + }, delay); + delay += bindingCombo.getSequenceDelayMs(); + } + } + public void handleInputEvent(Binding binding, boolean isActionDown, float offset) { if (binding == null || binding == Binding.NONE) return; diff --git a/app/src/main/java/com/winlator/widget/TouchpadView.java b/app/src/main/java/com/winlator/widget/TouchpadView.java index 23768aec08..2ab2e7b6bb 100644 --- a/app/src/main/java/com/winlator/widget/TouchpadView.java +++ b/app/src/main/java/com/winlator/widget/TouchpadView.java @@ -26,6 +26,7 @@ import com.winlator.xserver.XServer; import java.util.ArrayList; +import java.util.Collections; public class TouchpadView extends View implements View.OnCapturedPointerListener { private static final byte MAX_FINGERS = 4; @@ -54,6 +55,7 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener private int lastTouchedPosX; private int lastTouchedPosY; private static final Byte CLICK_DELAYED_TIME = 50; + private static final int SEQUENCE_PRESS_MS = 80; private static final Byte EFFECTIVE_TOUCH_DISTANCE = 20; private float resolutionScale; private static final int UPDATE_FORM_DELAYED_TIME = 50; @@ -63,6 +65,8 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener private String delayedPressAction; private Runnable pendingHoldClickRelease; private String pendingHoldClickReleaseAction; + private final ArrayList activeSequenceActions = new ArrayList<>(); + private int actionSequenceGeneration; private boolean pressExecuted; private final boolean capturePointerOnExternalMouse; @@ -90,6 +94,7 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener private boolean dragButtonPressed; private boolean holdMouseButtonTouchActive; private String holdMouseButtonTouchAction; + private ArrayList activePanModifierActions; // Two-finger tracking private boolean twoFingerDragging; @@ -1006,7 +1011,7 @@ private void handleTsMove(MotionEvent event, int pointerIndex) { // direction on their next move frame). if (isClickDragAction(dragAction)) { if (useRelativeMouseDragMovement()) { - pressClickDragButton(buttonForClickDragAction(dragAction)); + pressClickDragAction(dragAction); } else { performClickDragAt(event.getX(pointerIndex), event.getY(pointerIndex), dragAction); } @@ -1310,6 +1315,7 @@ private void handleTsCancel() { cancelLongPressTimer(); cancelTwoFingerHoldTimer(); cancelThreeFingerHoldTimer(); + cancelActionSequences(); stopGestureRefresh(); flushPendingHoldClickRelease(); // A tap's release is normally scheduled via delayedPress so we can @@ -1601,6 +1607,18 @@ private void moveCursorTo(int x, int y) { private boolean injectClick(String action) { if (action == null) return false; + ArrayList parts = actionParts(action); + if (parts.size() > 1) { + if (isActionSequence(action)) { + performActionSequence(parts, actionSequenceDelayMs(action)); + return false; + } + boolean held = false; + for (String part : parts) { + if (injectClick(part)) held = true; + } + return held; + } if (TouchGestureConfig.ACTION_SHOW_KEYBOARD.equals(action)) { if (showKeyboardCallback != null) showKeyboardCallback.run(); notifyGesture("Keyboard"); @@ -1639,6 +1657,14 @@ private boolean injectClick(String action) { private void injectRelease(String action) { if (action == null) return; + if (isActionSequence(action)) return; + ArrayList parts = actionParts(action); + if (parts.size() > 1) { + for (int i = parts.size() - 1; i >= 0; i--) { + injectRelease(parts.get(i)); + } + return; + } if (TouchGestureConfig.ACTION_SHOW_KEYBOARD.equals(action)) { return; // One-shot action, no release needed } @@ -1690,9 +1716,109 @@ private boolean releaseHoldAction(boolean held, String action) { } private boolean isMouseClickAction(String action) { - return TouchGestureConfig.ACTION_LEFT_CLICK.equals(action) - || TouchGestureConfig.ACTION_RIGHT_CLICK.equals(action) - || TouchGestureConfig.ACTION_MIDDLE_CLICK.equals(action); + String primaryAction = primaryAction(action); + return TouchGestureConfig.ACTION_LEFT_CLICK.equals(primaryAction) + || TouchGestureConfig.ACTION_RIGHT_CLICK.equals(primaryAction) + || TouchGestureConfig.ACTION_MIDDLE_CLICK.equals(primaryAction); + } + + private ArrayList actionParts(String action) { + ArrayList parts = new ArrayList<>(); + if (action == null || action.isEmpty()) return parts; + boolean isSequence = isActionSequence(action); + boolean isCombo = action.startsWith(TouchGestureConfig.ACTION_COMBO_PREFIX); + if (!isSequence && !isCombo) { + parts.add(action); + return parts; + } + String value = action.substring(isSequence + ? TouchGestureConfig.ACTION_SEQUENCE_PREFIX.length() + : TouchGestureConfig.ACTION_COMBO_PREFIX.length()); + if (isSequence) { + int delaySeparator = value.indexOf(':'); + if (delaySeparator > 0 && isDigits(value.substring(0, delaySeparator))) { + value = value.substring(delaySeparator + 1); + } + } + String[] split = value.split("\\|"); + for (String part : split) { + if (part == null || part.isEmpty() || parts.contains(part)) continue; + parts.add(part); + } + if (!isSequence) { + Collections.sort(parts, (a, b) -> Integer.compare(actionComboSortGroup(a), actionComboSortGroup(b))); + } + while (parts.size() > TouchGestureConfig.MAX_ACTION_COMBO_SIZE) parts.remove(parts.size() - 1); + return parts; + } + + private String primaryAction(String action) { + ArrayList parts = actionParts(action); + return parts.isEmpty() ? action : parts.get(parts.size() - 1); + } + + private int actionComboSortGroup(String action) { + if ("key_CTRL_L".equals(action) || "key_CTRL_R".equals(action) + || "key_SHIFT_L".equals(action) || "key_SHIFT_R".equals(action) + || "key_ALT_L".equals(action) || "key_ALT_R".equals(action)) { + return 0; + } + return 1; + } + + private boolean isActionSequence(String action) { + return action != null && action.startsWith(TouchGestureConfig.ACTION_SEQUENCE_PREFIX); + } + + private boolean isDigits(String value) { + if (value == null || value.isEmpty()) return false; + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) return false; + } + return true; + } + + private int actionSequenceDelayMs(String action) { + if (!isActionSequence(action)) return TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS; + String value = action.substring(TouchGestureConfig.ACTION_SEQUENCE_PREFIX.length()); + int separator = value.indexOf(':'); + if (separator <= 0 || !isDigits(value.substring(0, separator))) { + return TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS; + } + try { + int delayMs = Integer.parseInt(value.substring(0, separator)); + return Math.max( + TouchGestureConfig.MIN_ACTION_SEQUENCE_DELAY_MS, + Math.min(TouchGestureConfig.MAX_ACTION_SEQUENCE_DELAY_MS, delayMs)); + } catch (NumberFormatException e) { + return TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS; + } + } + + private void performActionSequence(ArrayList parts, int sequenceDelayMs) { + final int generation = actionSequenceGeneration; + int delay = 0; + for (String part : parts) { + postDelayed(() -> { + if (generation != actionSequenceGeneration) return; + if (injectClick(part)) { + activeSequenceActions.add(part); + postDelayed(() -> { + if (generation != actionSequenceGeneration) return; + if (activeSequenceActions.remove(part)) injectRelease(part); + }, SEQUENCE_PRESS_MS); + } + }, delay); + delay += sequenceDelayMs; + } + } + + private void cancelActionSequences() { + actionSequenceGeneration++; + for (int i = activeSequenceActions.size() - 1; i >= 0; i--) { + injectRelease(activeSequenceActions.get(i)); + } + activeSequenceActions.clear(); } private XKeycode actionToKeycode(String action) { @@ -1756,6 +1882,8 @@ private void performZoomAction(boolean zoomIn) { } private void performPanAction(float dx, float dy, String action) { + pressPanComboModifiers(action); + action = primaryAction(action); switch (action) { case TouchGestureConfig.PAN_MIDDLE_MOUSE: performMiddleMousePan(dx, dy); @@ -1785,8 +1913,9 @@ private void performPanAction(float dx, float dy, String action) { } private boolean isClickDragAction(String action) { - return TouchGestureConfig.PAN_LEFT_CLICK_DRAG.equals(action) - || TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(action); + String primaryAction = primaryAction(action); + return TouchGestureConfig.PAN_LEFT_CLICK_DRAG.equals(primaryAction) + || TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(primaryAction); } private boolean useRelativeMouseDragMovement() { @@ -1794,12 +1923,12 @@ private boolean useRelativeMouseDragMovement() { } private Pointer.Button buttonForClickDragAction(String action) { - return TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(action) + return TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(primaryAction(action)) ? Pointer.Button.BUTTON_RIGHT : Pointer.Button.BUTTON_LEFT; } private void performClickDragAt(float rawX, float rawY, String action) { - pressClickDragButton(buttonForClickDragAction(action)); + pressClickDragAction(action); float[] pt = XForm.transformPoint(xform, rawX, rawY); moveCursorTo((int) pt[0], (int) pt[1]); } @@ -1818,10 +1947,15 @@ private void performClickDrag(float dx, float dy, Pointer.Button button) { } private void performClickDragWithRelativeMovement(float dx, float dy, String action) { - pressClickDragButton(buttonForClickDragAction(action)); + pressClickDragAction(action); moveCursorByRelativeDelta(dx, dy); } + private void pressClickDragAction(String action) { + pressPanComboModifiers(action); + pressClickDragButton(buttonForClickDragAction(action)); + } + private void pressClickDragButton(Pointer.Button button) { if (button == Pointer.Button.BUTTON_LEFT && !leftClickDragButtonDown) { xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT); @@ -1894,6 +2028,7 @@ private void releaseAllDragButtons() { releaseTwoFingerMiddleButton(); releaseClickDragButtons(); resetRelativeDragRemainder(); + releasePanComboModifiers(); } private void resetRelativeDragRemainder() { @@ -1901,6 +2036,34 @@ private void resetRelativeDragRemainder() { relativeDragRemainderY = 0; } + private void pressPanComboModifiers(String action) { + if (isActionSequence(action)) { + releasePanComboModifiers(); + return; + } + ArrayList parts = actionParts(action); + if (parts.size() <= 1) { + releasePanComboModifiers(); + return; + } + + ArrayList modifiers = new ArrayList<>(parts); + modifiers.remove(modifiers.size() - 1); + if (activePanModifierActions != null && activePanModifierActions.equals(modifiers)) return; + + releasePanComboModifiers(); + activePanModifierActions = modifiers; + for (String modifier : activePanModifierActions) injectClick(modifier); + } + + private void releasePanComboModifiers() { + if (activePanModifierActions == null) return; + for (int i = activePanModifierActions.size() - 1; i >= 0; i--) { + injectRelease(activePanModifierActions.get(i)); + } + activePanModifierActions = null; + } + private void performKeyPan(float dx, float dy, XKeycode leftKey, XKeycode rightKey, XKeycode upKey, XKeycode downKey) { // When fingers are actively moving, update key states to match the // swipe direction. When fingers stop moving but stay on screen, diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 561012c6b2..4a1203b3c3 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2236,4 +2236,8 @@ Kan gøre spilstart fra eksternt lager markant hurtigere, men kan give problemer i nogle spil Deaktiver libredirect Kan forbedre ydeevnen, men kan også give uventede problemer + Samtidig + Sekvens + Sekvensforsinkelse (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c86ecbd051..62b83672e4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2306,4 +2306,8 @@ Kann den Spielstart von externem Speicher deutlich beschleunigen, kann aber bei manchen Spielen Probleme verursachen libredirect deaktivieren Kann die Leistung verbessern, aber auch unerwartete Probleme verursachen + Gleichzeitig + Sequenz + Sequenzverzögerung (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e7366db0a8..4a5cb3a34f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2364,4 +2364,8 @@ Puede acelerar considerablemente el inicio de juegos desde almacenamiento externo, pero puede causar problemas en algunos juegos Desactivar libredirect Puede mejorar el rendimiento, pero también puede causar problemas inesperados + Simultáneo + Secuencia + Retraso de secuencia (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 36e2cefdb2..531c840e6f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2366,4 +2366,8 @@ Peut accélérer considérablement le démarrage des jeux depuis le stockage externe, mais peut causer des problèmes dans certains jeux Désactiver libredirect Peut améliorer les performances, mais peut aussi causer des problèmes inattendus + Simultané + Séquence + Délai de séquence (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 18772c48f4..889d2b6dbf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2357,4 +2357,8 @@ Può velocizzare notevolmente l\'avvio dei giochi dalla memoria esterna, ma può causare problemi in alcuni giochi Disattiva libredirect Può migliorare le prestazioni, ma può anche causare problemi imprevisti + Simultaneo + Sequenza + Ritardo sequenza (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9e42f29549..05351d3ce1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2320,4 +2320,8 @@ 外部ストレージからのゲーム起動を大幅に高速化できますが、一部のゲームで問題が発生する可能性があります libredirect を無効にする パフォーマンスが向上する場合がありますが、予期しない問題が発生する可能性もあります + 同時 + シーケンス + シーケンス遅延 (ミリ秒) + %1$s(%2$d ミリ秒) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b7e262bae0..63782348ea 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2361,4 +2361,8 @@ 외부 저장소에서의 게임 부팅 속도를 크게 높일 수 있지만 일부 게임에서 문제가 발생할 수 있습니다 libredirect 비활성화 성능이 향상될 수 있지만 예기치 않은 문제가 발생할 수도 있습니다 + 동시 + 순차 + 순차 지연 (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ebd6c57cb6..de74c80fdf 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2370,4 +2370,8 @@ Może znacznie przyspieszyć uruchamianie gier z pamięci zewnętrznej, ale może powodować problemy w niektórych grach Wyłącz libredirect Może poprawić wydajność, ale może też powodować nieoczekiwane problemy + Jednocześnie + Sekwencja + Opóźnienie sekwencji (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0ccb209480..f77735772c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2236,4 +2236,8 @@ Pode acelerar bastante a inicialização de jogos no armazenamento externo, mas pode causar problemas em alguns jogos Desativar libredirect Pode melhorar o desempenho, mas também pode causar problemas inesperados + Simultâneo + Sequência + Atraso da sequência (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index be8ac908ae..c2e0b84662 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2370,4 +2370,8 @@ Poate accelera semnificativ pornirea jocurilor de pe stocarea externă, dar poate cauza probleme în unele jocuri Dezactivează libredirect Poate îmbunătăți performanța, dar poate cauza și probleme neașteptate + Simultan + Secvență + Întârziere secvență (ms) + %1$s (%2$d ms) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bc4ad17d80..b587b6be3d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2298,4 +2298,8 @@ https://gamenative.app Может значительно ускорить запуск игр с внешнего накопителя, но может вызывать проблемы в некоторых играх Отключить libredirect Может повысить производительность, но также может вызвать непредвиденные проблемы + Одновременно + Последовательно + Задержка последовательности (мс) + %1$s (%2$d мс) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8b741045a4..0081e48581 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2366,4 +2366,8 @@ Може значно пришвидшити запуск ігор із зовнішнього накопичувача, але може спричиняти проблеми в деяких іграх Вимкнути libredirect Може підвищити продуктивність, але також може спричинити неочікувані проблеми + Одночасно + Послідовно + Затримка послідовності (мс) + %1$s (%2$d мс) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e6b1fef5f5..19e8d44b09 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2381,4 +2381,8 @@ 可显著加快从外部存储启动游戏的速度,但可能导致部分游戏出现问题 禁用 libredirect 可能提升性能,但也可能导致意外问题 + 同时 + 顺序 + 顺序延迟(毫秒) + %1$s(%2$d 毫秒) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d59f3582e2..f226e5cc9b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2372,4 +2372,8 @@ 可顯著加快從外部儲存空間啟動遊戲的速度,但可能導致部分遊戲出現問題 停用 libredirect 可能提升效能,但也可能導致意外問題 + 同時 + 順序 + 順序延遲(毫秒) + %1$s(%2$d 毫秒) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 286cbe7248..11a8429c71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2367,4 +2367,8 @@ Conservative AdjustsGradual gradually with a focus on stability VR Refresh Rate + Simultaneous + Sequence + Sequence delay (ms) + %1$s (%2$d ms) diff --git a/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt b/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt index 48dbf21262..839c20825e 100644 --- a/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt +++ b/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt @@ -46,4 +46,36 @@ class TouchGestureConfigTest { assertEquals(ACTION_RIGHT_CLICK, actual.holdMouseButtonWhileTouchingAction) assertTrue(actual.showCursorInTouchscreenMode) } + + @Test + fun `action combo encodes and decodes with modifiers first`() { + val combo = TouchGestureConfig.actionComboOf(listOf("key_1", "key_CTRL_L")) + + assertEquals("combo:key_CTRL_L|key_1", combo) + assertEquals(listOf("key_CTRL_L", "key_1"), TouchGestureConfig.actionParts(combo)) + assertEquals("key_1", TouchGestureConfig.primaryAction(combo)) + } + + @Test + fun `action sequence preserves selected order`() { + val sequence = TouchGestureConfig.actionComboOf( + listOf("key_E", TouchGestureConfig.ACTION_LEFT_CLICK), + sequence = true, + sequenceDelayMs = 220, + ) + + assertEquals("seq:220:key_E|left_click", sequence) + assertEquals(listOf("key_E", TouchGestureConfig.ACTION_LEFT_CLICK), TouchGestureConfig.actionParts(sequence)) + assertEquals(TouchGestureConfig.ACTION_LEFT_CLICK, TouchGestureConfig.primaryAction(sequence)) + assertEquals(220, TouchGestureConfig.actionSequenceDelayMs(sequence)) + assertTrue(TouchGestureConfig.isActionSequence(sequence)) + } + + @Test + fun `old action sequence defaults delay`() { + val sequence = "seq:key_E|left_click" + + assertEquals(listOf("key_E", TouchGestureConfig.ACTION_LEFT_CLICK), TouchGestureConfig.actionParts(sequence)) + assertEquals(TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS, TouchGestureConfig.actionSequenceDelayMs(sequence)) + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt new file mode 100644 index 0000000000..68293d6879 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt @@ -0,0 +1,80 @@ +package com.winlator.inputcontrols + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class BindingComboTest { + @Test + fun `combo normalizes modifiers before primary binding`() { + val combo = BindingCombo.fromBindings(listOf(Binding.KEY_1, Binding.KEY_CTRL_L)) + + assertEquals(listOf(Binding.KEY_CTRL_L, Binding.KEY_1), combo.bindings) + assertEquals(Binding.KEY_1, combo.primaryBinding) + assertEquals("L CTRL + 1", combo.toString()) + } + + @Test + fun `combo reads nested binding arrays`() { + val combo = BindingCombo.fromJsonValue(JSONArray(listOf("KEY_SHIFT_L", "MOUSE_RIGHT_BUTTON"))) + + assertEquals(listOf(Binding.KEY_SHIFT_L, Binding.MOUSE_RIGHT_BUTTON), combo.bindings) + } + + @Test + fun `simultaneous mode is the persisted mode name`() { + assertEquals("simultaneous", BindingCombo.Mode.SIMULTANEOUS.jsonName) + } + + @Test + fun `unknown mode loads as simultaneous`() { + val json = JSONObject() + .put("mode", "unknown") + .put("bindings", JSONArray(listOf("KEY_CTRL_L", "KEY_1"))) + + val combo = BindingCombo.fromJsonValue(json) + + assertEquals(BindingCombo.Mode.SIMULTANEOUS, combo.mode) + assertEquals(listOf(Binding.KEY_CTRL_L, Binding.KEY_1), combo.bindings) + } + + @Test + fun `sequence preserves selected order`() { + val combo = BindingCombo.fromBindings( + listOf(Binding.KEY_E, Binding.MOUSE_LEFT_BUTTON), + BindingCombo.Mode.SEQUENCE, + 220, + ) + + assertEquals(BindingCombo.Mode.SEQUENCE, combo.mode) + assertEquals(220, combo.sequenceDelayMs) + assertEquals(listOf(Binding.KEY_E, Binding.MOUSE_LEFT_BUTTON), combo.bindings) + assertEquals(Binding.MOUSE_LEFT_BUTTON, combo.primaryBinding) + assertEquals("E -> LEFT BUTTON", combo.toString()) + } + + @Test + fun `sequence round trips through json object`() { + val expected = BindingCombo.fromBindings( + listOf(Binding.KEY_E, Binding.MOUSE_LEFT_BUTTON), + BindingCombo.Mode.SEQUENCE, + 220, + ) + + val actual = BindingCombo.fromJsonValue(expected.toJsonValue() as JSONObject) + + assertEquals(BindingCombo.Mode.SEQUENCE, actual.mode) + assertEquals(220, actual.sequenceDelayMs) + assertEquals(expected.bindings, actual.bindings) + } + + @Test + fun `equivalent combos compare equal`() { + val first = BindingCombo.fromBindings(listOf(Binding.KEY_1, Binding.KEY_CTRL_L)) + val second = BindingCombo.fromBindings(listOf(Binding.KEY_CTRL_L, Binding.KEY_1)) + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + } +} From 35c90860a81742467f9fd98c421942f3b960d548 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Mon, 13 Jul 2026 19:53:13 -0500 Subject: [PATCH 2/8] Support multi-action radial menu slots --- .../dialog/RadialMenuSettingsDialog.kt | 23 ++++++---- .../screen/xserver/RadialMenuCoordinator.kt | 37 ++++++++++++---- .../winlator/inputcontrols/RadialMenu.java | 42 +++++++++++++++---- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/RadialMenuSettingsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/RadialMenuSettingsDialog.kt index 6f5bd38a50..426277ca23 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/RadialMenuSettingsDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/RadialMenuSettingsDialog.kt @@ -52,6 +52,7 @@ import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt import com.alorma.compose.settings.ui.SettingsMenuLink import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.RadialMenu import kotlin.math.roundToInt @@ -76,9 +77,9 @@ fun RadialMenuSettingsContent( } } val bindings = remember(menu) { - mutableStateListOf().apply { + mutableStateListOf().apply { repeat(RadialMenu.MAX_SLOTS) { index -> - add(initialSlots.getOrNull(index)?.binding ?: Binding.NONE) + add(initialSlots.getOrNull(index)?.bindingCombo ?: BindingCombo.none()) } } } @@ -92,7 +93,7 @@ fun RadialMenuSettingsContent( slotCount = preset.size.coerceIn(1, RadialMenu.MAX_SLOTS) for (index in 0 until RadialMenu.MAX_SLOTS) { labels[index] = preset.getOrNull(index)?.first.orEmpty() - bindings[index] = preset.getOrNull(index)?.second ?: Binding.NONE + bindings[index] = BindingCombo.of(preset.getOrNull(index)?.second) } } @@ -185,10 +186,16 @@ fun RadialMenuSettingsContent( bindingSlotToEdit?.let { slotIndex -> ControllerBindingDialog( buttonName = stringResource(R.string.radial_menu_slot_binding, slotIndex + 1), - currentBinding = bindings[slotIndex].takeIf { it != Binding.NONE }, + currentBinding = bindings[slotIndex].primaryBinding.takeIf { it != Binding.NONE }, + currentBindingCombo = bindings[slotIndex], onDismiss = { bindingSlotToEdit = null }, - onBindingSelected = { binding -> - bindings[slotIndex] = if (binding == Binding.OPEN_RADIAL_MENU) Binding.NONE else binding ?: Binding.NONE + onBindingSelected = {}, + onBindingComboSelected = { bindingCombo -> + bindings[slotIndex] = if (bindingCombo?.bindings?.contains(Binding.OPEN_RADIAL_MENU) == true) { + BindingCombo.none() + } else { + bindingCombo ?: BindingCombo.none() + } bindingSlotToEdit = null }, ) @@ -327,11 +334,11 @@ private fun SlotCountSetting( private fun SlotSetting( index: Int, label: String, - binding: Binding, + binding: BindingCombo, onLabelChange: (String) -> Unit, onEditBinding: () -> Unit, ) { - val bindingText = binding.takeIf { it != Binding.NONE }?.toString() ?: stringResource(R.string.not_set) + val bindingText = if (binding.isEmpty) stringResource(R.string.not_set) else binding.toString() SettingsMenuLink( colors = if (index % 2 == 0) settingsTileColorsAlt() else settingsTileColors(), title = { Text(stringResource(R.string.radial_menu_slot_title, index + 1)) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt index f4076210ba..373206e3b1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt @@ -18,6 +18,7 @@ import app.gamenative.ui.component.dialog.RadialMenuSettingsContent import app.gamenative.ui.theme.PluviaTheme import com.winlator.container.Container import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.BindingCombo import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.InputControlsManager @@ -403,14 +404,14 @@ class RadialMenuCoordinator( val slots = activeMenu()?.enabledSlots.orEmpty() val selectedIndex = overlayView.selectedIndex() val selectedBinding = if (commit && selectedIndex in slots.indices) { - slots[selectedIndex].binding + slots[selectedIndex].bindingCombo } else { - Binding.NONE + BindingCombo.none() } overlayView.hide() activeTouchPointerId = MotionEvent.INVALID_POINTER_ID inputControlSelectionActive = false - if (selectedBinding != Binding.NONE) dispatchBinding(selectedBinding) + if (!selectedBinding.isEmpty) dispatchBinding(selectedBinding) } private fun updateSelection(point: PointF) { @@ -474,15 +475,35 @@ class RadialMenuCoordinator( return (((normalized + sweep / 2f) / sweep).toInt() % slots.size) } - private fun dispatchBinding(binding: Binding) { - if (binding == Binding.NONE || binding == Binding.OPEN_RADIAL_MENU) return - val offset = bindingOffset(binding) - applyBinding(binding, true, offset) + private fun dispatchBinding(bindingCombo: BindingCombo) { + if (bindingCombo.isEmpty || Binding.OPEN_RADIAL_MENU in bindingCombo.bindings) return + + if (bindingCombo.isSequence) { + dispatchBindingSequence(bindingCombo) + return + } + + for (binding in bindingCombo.bindings) { + applyBinding(binding, true, bindingOffset(binding)) + } host.postDelayed({ - applyBinding(binding, false, 0f) + bindingCombo.bindings.asReversed().forEach { binding -> + applyBinding(binding, false, 0f) + } }, 70L) } + private fun dispatchBindingSequence(bindingCombo: BindingCombo) { + bindingCombo.bindings.forEachIndexed { index, binding -> + host.postDelayed({ + applyBinding(binding, true, bindingOffset(binding)) + host.postDelayed({ + applyBinding(binding, false, 0f) + }, 70L) + }, index * bindingCombo.sequenceDelayMs.toLong()) + } + } + private fun bindingOffset(binding: Binding): Float { return when (binding) { Binding.GAMEPAD_LEFT_THUMB_UP, diff --git a/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java b/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java index 8b162e989d..531a92a454 100644 --- a/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java +++ b/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java @@ -19,7 +19,7 @@ public class RadialMenu { public static class Slot { private String label = ""; - private Binding binding = Binding.NONE; + private BindingCombo bindingCombo = BindingCombo.none(); public Slot() { } @@ -29,6 +29,11 @@ public Slot(String label, Binding binding) { setBinding(binding); } + public Slot(String label, BindingCombo bindingCombo) { + setLabel(label); + setBindingCombo(bindingCombo); + } + public String getLabel() { return label; } @@ -38,32 +43,55 @@ public void setLabel(String label) { } public Binding getBinding() { - return binding; + return bindingCombo.getPrimaryBinding(); } public void setBinding(Binding binding) { - this.binding = binding != null ? binding : Binding.NONE; + this.bindingCombo = BindingCombo.of(binding); + } + + public BindingCombo getBindingCombo() { + return bindingCombo; + } + + public void setBindingCombo(BindingCombo bindingCombo) { + this.bindingCombo = bindingCombo != null ? bindingCombo : BindingCombo.none(); } public boolean isEnabled() { - return binding != Binding.NONE; + return !bindingCombo.isEmpty(); } public String getDisplayLabel() { - return !label.isEmpty() ? label : binding.toString(); + return !label.isEmpty() ? label : bindingCombo.toString(); } public JSONObject toJSONObject() throws JSONException { JSONObject object = new JSONObject(); object.put("label", label); - object.put("binding", binding.name()); + object.put("binding", getBinding().name()); + if (!bindingCombo.isSingleBinding()) { + object.put("bindings", bindingCombo.toJsonArray()); + if (bindingCombo.isSequence()) { + object.put("bindingMode", bindingCombo.getMode().getJsonName()); + object.put("bindingDelayMs", bindingCombo.getSequenceDelayMs()); + } + } return object; } public static Slot fromJSONObject(JSONObject object) { Slot slot = new Slot(); slot.setLabel(object.optString("label", "")); - slot.setBinding(Binding.fromString(object.optString("binding", Binding.NONE.name()))); + if (object.has("bindings")) { + BindingCombo.Mode mode = BindingCombo.Mode.fromJsonName( + object.optString("bindingMode", BindingCombo.Mode.SIMULTANEOUS.getJsonName())); + int delayMs = object.optInt("bindingDelayMs", BindingCombo.DEFAULT_SEQUENCE_DELAY_MS); + slot.setBindingCombo(BindingCombo.fromJsonArray(object.optJSONArray("bindings"), mode, delayMs)); + } + else { + slot.setBinding(Binding.fromString(object.optString("binding", Binding.NONE.name()))); + } return slot; } } From ad03c88414fbd135413c5fd4b277d5b1983a90df Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 18 Aug 2026 20:28:41 -0500 Subject: [PATCH 3/8] Harden multi-action cancellation paths --- .../xserver/PhysicalControllerHandler.kt | 2 +- .../screen/xserver/RadialMenuCoordinator.kt | 45 ++++++++++++++--- .../inputcontrols/ControlElement.java | 50 ++++++++++++------- .../ControlElementCancellationTest.kt | 18 +++++++ .../winlator/inputcontrols/RadialMenuTest.kt | 50 +++++++++++++++++++ 5 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt 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 65afbe44d6..bd80767cff 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 @@ -73,7 +73,7 @@ class PhysicalControllerHandler( sourceController = controller, ) } - } + } } fun setProfile(profile: ControlsProfile?) { diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt index 373206e3b1..e252abf3db 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt @@ -42,6 +42,8 @@ class RadialMenuCoordinator( private val onSettingsVisibilityChanged: (Boolean) -> Unit, ) : InputControlsView.RadialMenuListener { companion object { + private const val BINDING_PRESS_MS = 70L + fun install( context: Context, host: ViewGroup, @@ -91,6 +93,8 @@ class RadialMenuCoordinator( private var inputControlSelectionActive = false private var activeTouchPointerId = MotionEvent.INVALID_POINTER_ID private val wheelCenter = PointF() + private val activeDispatchedBindings = mutableListOf() + private var bindingDispatchGeneration = 0 init { host.addView( @@ -104,6 +108,7 @@ class RadialMenuCoordinator( fun detach() { close(commit = false) + cancelBindingDispatches() inputControlsView?.setRadialMenuListener(null) touchpadView?.setOpenRadialMenuCallback(null) settingsDialog?.dismiss() @@ -114,6 +119,7 @@ class RadialMenuCoordinator( } fun bindInputControlsView(view: InputControlsView?) { + cancelBindingDispatches() inputControlsView?.setRadialMenuListener(null) inputControlsView = view view?.let { overlayView.setControlsStyle(it.primaryColor, it.secondaryColor) } @@ -133,6 +139,7 @@ class RadialMenuCoordinator( } fun setProfile(profile: ControlsProfile?) { + cancelBindingDispatches() activeControlsProfile = profile } @@ -347,6 +354,7 @@ class RadialMenuCoordinator( } private fun applyProfile(profile: ControlsProfile?) { + cancelBindingDispatches() activeControlsProfile = profile if (profile != null) { if (inputControlsView?.profile != null) { @@ -477,33 +485,58 @@ class RadialMenuCoordinator( private fun dispatchBinding(bindingCombo: BindingCombo) { if (bindingCombo.isEmpty || Binding.OPEN_RADIAL_MENU in bindingCombo.bindings) return + cancelBindingDispatches() if (bindingCombo.isSequence) { dispatchBindingSequence(bindingCombo) return } + val generation = bindingDispatchGeneration for (binding in bindingCombo.bindings) { - applyBinding(binding, true, bindingOffset(binding)) + pressDispatchedBinding(binding) } host.postDelayed({ + if (generation != bindingDispatchGeneration) return@postDelayed bindingCombo.bindings.asReversed().forEach { binding -> - applyBinding(binding, false, 0f) + releaseDispatchedBinding(binding) } - }, 70L) + }, BINDING_PRESS_MS) } private fun dispatchBindingSequence(bindingCombo: BindingCombo) { + val generation = bindingDispatchGeneration bindingCombo.bindings.forEachIndexed { index, binding -> host.postDelayed({ - applyBinding(binding, true, bindingOffset(binding)) + if (generation != bindingDispatchGeneration) return@postDelayed + pressDispatchedBinding(binding) host.postDelayed({ - applyBinding(binding, false, 0f) - }, 70L) + if (generation != bindingDispatchGeneration) return@postDelayed + releaseDispatchedBinding(binding) + }, BINDING_PRESS_MS) }, index * bindingCombo.sequenceDelayMs.toLong()) } } + private fun pressDispatchedBinding(binding: Binding) { + applyBinding(binding, true, bindingOffset(binding)) + if (binding != Binding.OPEN_NAVIGATION_MENU && binding != Binding.SHOW_KEYBOARD) { + activeDispatchedBindings.add(binding) + } + } + + private fun releaseDispatchedBinding(binding: Binding) { + if (activeDispatchedBindings.remove(binding)) applyBinding(binding, false, 0f) + } + + private fun cancelBindingDispatches() { + bindingDispatchGeneration++ + activeDispatchedBindings.asReversed().forEach { binding -> + applyBinding(binding, false, 0f) + } + activeDispatchedBindings.clear() + } + private fun bindingOffset(binding: Binding): Float { return when (binding) { Binding.GAMEPAD_LEFT_THUMB_UP, diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index 2650671da4..03347a3406 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -251,6 +251,22 @@ public void setBinding(Binding binding) { Arrays.fill(bindings, BindingCombo.of(binding)); } + private void handleBindingInputEvent(int index, boolean isActionDown) { + BindingCombo bindingCombo = getBindingComboAt(index); + if (bindingCombo.isSingleBinding()) { + inputControlsView.handleInputEvent(bindingCombo.getPrimaryBinding(), isActionDown); + } + else inputControlsView.handleInputEvent(bindingCombo, isActionDown); + } + + private void handleBindingInputEvent(int index, boolean isActionDown, float offset) { + BindingCombo bindingCombo = getBindingComboAt(index); + if (bindingCombo.isSingleBinding()) { + inputControlsView.handleInputEvent(bindingCombo.getPrimaryBinding(), isActionDown, offset); + } + else inputControlsView.handleInputEvent(bindingCombo, isActionDown, offset); + } + public String getShooterMovementType() { return shooterMovementType; } @@ -1123,7 +1139,7 @@ private boolean handleRadialMenuDirectionalMove(int pointerId, boolean[] directi private void releaseActiveDirectionalStates() { for (byte i = 0; i < states.length && i < bindings.length; i++) { - if (states[i]) inputControlsView.handleInputEvent(getBindingComboAt(i), false); + if (states[i]) handleBindingInputEvent(i, false); states[i] = false; } } @@ -1142,8 +1158,8 @@ public boolean handleTouchDown(int pointerId, float x, float y) { if (isKeepButtonPressedAfterMinTime()) touchTime = System.currentTimeMillis(); if (!toggleSwitch || !selected) { currentPointerActivatedButtonBindings = !selected; - inputControlsView.handleInputEvent(getBindingComboAt(0), true); - inputControlsView.handleInputEvent(getBindingComboAt(1), true); + handleBindingInputEvent(0, true); + handleBindingInputEvent(1, true); } inputControlsView.invalidate(); return true; @@ -1219,12 +1235,12 @@ public boolean handleTouchMove(int pointerId, float x, float y) { Binding binding = getBindingAt(i); if (binding.isGamepad()) { value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * STICK_SENSITIVITY, -1, 1); - inputControlsView.handleInputEvent(getBindingComboAt(i), true, value); + handleBindingInputEvent(i, true, value); this.states[i] = true; } else { boolean state = binding.isMouseMove() ? (states[i] || states[(i+2)%4]) : states[i]; - inputControlsView.handleInputEvent(getBindingComboAt(i), state, value); + handleBindingInputEvent(i, state, value); this.states[i] = state; } } @@ -1245,7 +1261,7 @@ else if (type == Type.TRACKPAD) { if (Math.abs(value) > TRACKPAD_ACCELERATION_THRESHOLD) value *= STICK_SENSITIVITY; interpolator.set(0.075f, 0.95f, 0.45f, 0.95f); float interpolatedValue = interpolator.getInterpolation(Math.min(1.0f, Math.abs(value / TRACKPAD_MAX_SPEED))); - inputControlsView.handleInputEvent(getBindingComboAt(i), true, Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1)); + handleBindingInputEvent(i, true, Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1)); this.states[i] = true; } else { @@ -1257,7 +1273,7 @@ else if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) cursorDy = Mathf.roundPoint(value); } else { - inputControlsView.handleInputEvent(getBindingComboAt(i), states[i], value); + handleBindingInputEvent(i, states[i], value); this.states[i] = states[i]; } } @@ -1275,13 +1291,13 @@ else if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; if (this.states[i] && !states[i]) { - inputControlsView.handleInputEvent(getBindingComboAt(i), false, value); + handleBindingInputEvent(i, false, value); } } for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; - if (states[i]) inputControlsView.handleInputEvent(getBindingComboAt(i), true, value); + if (states[i]) handleBindingInputEvent(i, true, value); this.states[i] = states[i]; } @@ -1319,15 +1335,15 @@ public boolean handleTouchUp(int pointerId) { else if (isKeepButtonPressedAfterMinTime() && touchTime != null) { selected = (System.currentTimeMillis() - (long)touchTime) > BUTTON_MIN_TIME_TO_KEEP_PRESSED; if (!selected) { - inputControlsView.handleInputEvent(getBindingComboAt(0), false); - inputControlsView.handleInputEvent(getBindingComboAt(1), false); + handleBindingInputEvent(0, false); + handleBindingInputEvent(1, false); } touchTime = null; inputControlsView.invalidate(); } else if (!toggleSwitch || selected) { - inputControlsView.handleInputEvent(getBindingComboAt(0), false); - inputControlsView.handleInputEvent(getBindingComboAt(1), false); + handleBindingInputEvent(0, false); + handleBindingInputEvent(1, false); } if (toggleSwitch) { @@ -1341,7 +1357,7 @@ else if (!toggleSwitch || selected) { } else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { for (byte i = 0; i < states.length; i++) { - if (states[i]) inputControlsView.handleInputEvent(getBindingComboAt(i), false); + if (states[i]) handleBindingInputEvent(i, false); states[i] = false; } @@ -1377,15 +1393,15 @@ public boolean cancelTouch() { } else if (type == Type.BUTTON) { if (currentPointerActivatedButtonBindings) { - inputControlsView.handleInputEvent(getBindingAt(0), false); - inputControlsView.handleInputEvent(getBindingAt(1), false); + handleBindingInputEvent(0, false); + handleBindingInputEvent(1, false); } currentPointerActivatedButtonBindings = false; touchTime = null; } else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { for (byte i = 0; i < states.length; i++) { - if (states[i]) inputControlsView.handleInputEvent(getBindingAt(i), false); + if (states[i]) handleBindingInputEvent(i, false); states[i] = false; } if (type == Type.RANGE_BUTTON) scroller.handleTouchUp(); diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementCancellationTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementCancellationTest.kt index 8db33058b3..601c50d51c 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementCancellationTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementCancellationTest.kt @@ -49,4 +49,22 @@ class ControlElementCancellationTest { verify(exactly = 0) { view.handleInputEvent(Binding.KEY_A, true) } verify(exactly = 0) { view.handleInputEvent(Binding.KEY_A, false) } } + + @Test + fun `cancelling a held multi-action button releases the entire combo`() { + val view = mockk(relaxed = true) + every { view.snappingSize } returns 10 + val combo = BindingCombo.fromBindings(listOf(Binding.KEY_CTRL_L, Binding.KEY_A)) + val element = ControlElement(view).apply { + setX(50) + setY(50) + setBindingComboAt(0, combo) + } + + assertTrue(element.handleTouchDown(9, 50f, 50f)) + assertTrue(element.cancelTouch()) + + verify(exactly = 1) { view.handleInputEvent(combo, true) } + verify(exactly = 1) { view.handleInputEvent(combo, false) } + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt b/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt new file mode 100644 index 0000000000..59b3d15cc8 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt @@ -0,0 +1,50 @@ +package com.winlator.inputcontrols + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class RadialMenuTest { + @Test + fun `legacy single binding still loads`() { + val slot = RadialMenu.Slot.fromJSONObject( + JSONObject() + .put("label", "Inventory") + .put("binding", "KEY_I"), + ) + + assertEquals("Inventory", slot.label) + assertEquals(BindingCombo.of(Binding.KEY_I), slot.bindingCombo) + } + + @Test + fun `simultaneous radial binding round trips`() { + val expected = BindingCombo.fromBindings(listOf(Binding.KEY_CTRL_L, Binding.KEY_1)) + val slot = RadialMenu.Slot("Action", expected) + + val json = slot.toJSONObject() + val restored = RadialMenu.Slot.fromJSONObject(json) + + assertEquals(Binding.KEY_1.name, json.getString("binding")) + assertEquals(expected, restored.bindingCombo) + } + + @Test + fun `radial sequence preserves order and delay`() { + val expected = BindingCombo.fromBindings( + listOf(Binding.KEY_E, Binding.MOUSE_LEFT_BUTTON), + BindingCombo.Mode.SEQUENCE, + 240, + ) + val slot = RadialMenu.Slot("Interact", expected) + + val json = slot.toJSONObject() + val restored = RadialMenu.Slot.fromJSONObject(json) + + assertEquals(JSONArray(listOf("KEY_E", "MOUSE_LEFT_BUTTON")).toString(), json.getJSONArray("bindings").toString()) + assertEquals("sequence", json.getString("bindingMode")) + assertEquals(240, json.getInt("bindingDelayMs")) + assertEquals(expected, restored.bindingCombo) + } +} From fb53b5d795da4574b31201dc38ced66a35c5ced0 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 18 Aug 2026 23:32:03 -0500 Subject: [PATCH 4/8] Fix multi-action dispatch edge cases --- .../app/gamenative/data/TouchGestureConfig.kt | 11 ++ .../dialog/ControllerBindingDialog.kt | 13 +- .../component/dialog/ElementEditorDialog.kt | 4 +- .../dialog/PhysicalControllerConfigSection.kt | 44 +++--- .../component/dialog/SettingsDialogBlocks.kt | 25 +++- .../dialog/TouchGestureSettingsDialog.kt | 25 +++- .../xserver/PhysicalControllerHandler.kt | 51 +++++-- .../winlator/inputcontrols/BindingCombo.java | 35 ++++- .../inputcontrols/ControlElement.java | 78 ++++++++--- .../inputcontrols/ControlsProfile.java | 10 +- .../ExternalControllerBinding.java | 6 +- .../winlator/inputcontrols/RadialMenu.java | 11 +- .../winlator/widget/InputControlsView.java | 44 +++++- .../com/winlator/widget/TouchpadView.java | 129 +++++------------- .../gamenative/data/TouchGestureConfigTest.kt | 9 ++ .../inputcontrols/BindingComboTest.kt | 27 ++++ .../ControlElementDPadRemapTest.kt | 44 ++++++ .../ControlElementLookThroughTest.kt | 15 ++ .../winlator/inputcontrols/RadialMenuTest.kt | 19 ++- 19 files changed, 390 insertions(+), 210 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt b/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt index 42940e71da..108e15f85d 100644 --- a/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt +++ b/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt @@ -349,6 +349,7 @@ data class TouchGestureConfig( } } + @JvmStatic fun actionParts(action: String?): List { if (action.isNullOrBlank()) return emptyList() val sequence = action.startsWith(ACTION_SEQUENCE_PREFIX) @@ -362,10 +363,12 @@ data class TouchGestureConfig( return if (sequence) parts else parts.sortedBy { actionComboSortGroup(it) } } + @JvmStatic fun isActionSequence(action: String?): Boolean { return action?.startsWith(ACTION_SEQUENCE_PREFIX) == true } + @JvmStatic fun actionSequenceDelayMs(action: String?): Int { if (!isActionSequence(action)) return DEFAULT_ACTION_SEQUENCE_DELAY_MS val payload = action.orEmpty().removePrefix(ACTION_SEQUENCE_PREFIX) @@ -377,10 +380,18 @@ data class TouchGestureConfig( ?: DEFAULT_ACTION_SEQUENCE_DELAY_MS } + @JvmStatic fun primaryAction(action: String?): String { return actionParts(action).lastOrNull() ?: ACTION_LEFT_CLICK } + @JvmStatic + fun containsMouseButtonAction(action: String?): Boolean { + return actionParts(action).any { + it == ACTION_LEFT_CLICK || it == ACTION_RIGHT_CLICK || it == ACTION_MIDDLE_CLICK + } + } + private fun actionPayload(action: String, sequence: Boolean): String { if (!sequence) return action.removePrefix(ACTION_COMBO_PREFIX) val payload = action.removePrefix(ACTION_SEQUENCE_PREFIX) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt index bfbc8429ac..d50e791a31 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerBindingDialog.kt @@ -189,13 +189,13 @@ fun ControllerBindingDialog( }, modifier = Modifier.size(40.dp) ) { - Icon(Icons.Default.Check, null) + Icon(Icons.Default.Check, stringResource(app.gamenative.R.string.save)) } IconButton( onClick = onDismiss, modifier = Modifier.size(40.dp) ) { - Icon(Icons.Default.Close, null) + Icon(Icons.Default.Close, stringResource(app.gamenative.R.string.close)) } } } @@ -455,12 +455,15 @@ fun ControllerBindingDialog( } } else { filteredBindings.forEach { binding -> + val isNone = binding == Binding.NONE BindingOption( binding = binding, - isSelected = binding in selectedBindings, - enabled = binding in selectedBindings || selectedBindings.size < BindingCombo.MAX_BINDINGS, + isSelected = if (isNone) selectedBindings.isEmpty() else binding in selectedBindings, + enabled = isNone || binding in selectedBindings || selectedBindings.size < BindingCombo.MAX_BINDINGS, onClick = { - if (binding in selectedBindings) { + if (isNone) { + selectedBindings.clear() + } else if (binding in selectedBindings) { selectedBindings.remove(binding) } else if (selectedBindings.size < BindingCombo.MAX_BINDINGS) { selectedBindings.add(binding) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt index c6477b2a9d..cd26441ad3 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ElementEditorDialog.kt @@ -105,7 +105,7 @@ fun ElementEditorDialog( val parts = text.split(" ") val sb = StringBuilder() for (part in parts) { - if (part.isNotEmpty()) sb.append(part[0]) + if (part.isNotEmpty() && part != "+" && part != "->") sb.append(part[0]) } text = (if (binding.primaryBinding.isMouse) "M" else "") + sb.toString() } @@ -1068,7 +1068,7 @@ fun ElementEditorDialog( val parts = newBindingText.split(" ") val sb = StringBuilder() for (part in parts) { - if (part.isNotEmpty()) sb.append(part[0]) + if (part.isNotEmpty() && part != "+" && part != "->") sb.append(part[0]) } (if (bindingCombo?.primaryBinding?.isMouse() == true) "M" else "") + sb.toString() } else { diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt index 8438037d81..d4e633a812 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/PhysicalControllerConfigSection.kt @@ -189,21 +189,21 @@ internal fun PhysicalControllerConfigSection( ) } + fun restoreOriginalBindings() { + controller?.let { ctrl -> + ctrl.getControllerBindings().toList().forEach(ctrl::removeControllerBinding) + for ((keyCode, binding) in originalBindings) { + val restoredBinding = ExternalControllerBinding() + restoredBinding.setKeyCode(keyCode) + restoredBinding.setBindingCombo(binding) + ctrl.addControllerBinding(restoredBinding) + } + } + } + Dialog( onDismissRequest = { - // Cancel: Restore original bindings - controller?.let { ctrl -> - val existingBindings = ctrl.getControllerBindings().toList() - for (binding in existingBindings) { - ctrl.removeControllerBinding(binding) - } - for ((keyCode, binding) in originalBindings) { - val newBinding = ExternalControllerBinding() - newBinding.setKeyCode(keyCode) - newBinding.setBindingCombo(binding) - ctrl.addControllerBinding(newBinding) - } - } + restoreOriginalBindings() onDismiss() }, properties = DialogProperties( @@ -225,22 +225,10 @@ internal fun PhysicalControllerConfigSection( }, navigationIcon = { IconButton(onClick = { - // Cancel: Restore original bindings - controller?.let { ctrl -> - val existingBindings = ctrl.getControllerBindings().toList() - for (binding in existingBindings) { - ctrl.removeControllerBinding(binding) - } - for ((keyCode, binding) in originalBindings) { - val newBinding = ExternalControllerBinding() - newBinding.setKeyCode(keyCode) - newBinding.setBindingCombo(binding) - ctrl.addControllerBinding(newBinding) - } - } + restoreOriginalBindings() onDismiss() }) { - Icon(Icons.Default.Close, null) + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) } }, actions = { @@ -904,7 +892,7 @@ private fun copyElementsIfNeeded(context: android.content.Context, destProfile: val bindings = element.getJSONArray("bindings") for (j in 0 until bindings.length()) { val binding = BindingCombo.fromJsonValue(bindings.get(j)) - if (binding.isGamepadOnly) { + if (binding.containsGamepadBinding()) { hasGamepadBindings = true break } 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 bc0e800a64..a196c180fa 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 @@ -133,9 +133,21 @@ fun DelayTextField( } var text by remember { mutableStateOf(clampedValue.toString()) } var isFocused by remember { mutableStateOf(false) } + var lastSubmittedValue by remember { mutableStateOf(clampedValue) } + + fun normalizedDraftValue(draft: String): Int? { + if (draft.isEmpty()) return null + return draft.toLongOrNull() + ?.coerceIn(valueRange.first.toLong(), valueRange.last.toLong()) + ?.toInt() + ?: valueRange.last + } LaunchedEffect(clampedValue, isFocused) { - if (!isFocused) text = clampedValue.toString() + if (!isFocused || clampedValue != lastSubmittedValue) { + text = clampedValue.toString() + lastSubmittedValue = clampedValue + } } NoExtractOutlinedTextField( @@ -143,9 +155,9 @@ fun DelayTextField( onValueChange = { newText -> val filtered = newText.filter { it.isDigit() } text = filtered - val parsed = filtered.toIntOrNull() - if (parsed != null && parsed in valueRange && parsed != value) { - onValueChange(parsed) + normalizedDraftValue(filtered)?.let { nextValue -> + lastSubmittedValue = nextValue + if (nextValue != value) onValueChange(nextValue) } }, label = { Text(label) }, @@ -157,10 +169,9 @@ fun DelayTextField( val wasFocused = isFocused isFocused = focusState.isFocused if (wasFocused && !focusState.isFocused) { - val nextValue = text.toIntOrNull() - ?.coerceIn(valueRange.first, valueRange.last) - ?: clampedValue + val nextValue = normalizedDraftValue(text) ?: clampedValue text = nextValue.toString() + lastSubmittedValue = nextValue if (nextValue != value) onValueChange(nextValue) } } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt index 40ce717d44..f46a2ea16d 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/TouchGestureSettingsDialog.kt @@ -537,8 +537,7 @@ private fun mouseBehaviorLabel(behavior: String): String = when (behavior) { } private fun isMouseButtonAction(action: String): Boolean { - val primaryAction = TouchGestureConfig.primaryAction(action) - return primaryAction == ACTION_LEFT_CLICK || primaryAction == ACTION_RIGHT_CLICK || primaryAction == ACTION_MIDDLE_CLICK + return TouchGestureConfig.containsMouseButtonAction(action) } @Composable @@ -754,6 +753,7 @@ private fun PanActionPicker( dialogTitle = stringResource(R.string.gesture_action_label), categories = buildPanActionCategories(), allowSequence = false, + requiredSingleSelectionActions = PAN_ACTIONS.toSet(), actionLabel = { action -> if (PAN_ACTIONS.contains(action)) panSingleActionLabel(action) else tapHoldSingleActionLabel(action) }, @@ -812,6 +812,7 @@ private fun TouchActionComboPicker( dialogTitle: String, categories: List, allowSequence: Boolean = true, + requiredSingleSelectionActions: Set = emptySet(), actionLabel: @Composable (String) -> String, onActionSelected: (String) -> Unit, ) { @@ -850,7 +851,9 @@ private fun TouchActionComboPicker( if (showDialog) { val selectedActions = remember(currentAction) { mutableStateListOf().apply { - addAll(TouchGestureConfig.actionParts(currentAction)) + val parts = TouchGestureConfig.actionParts(currentAction) + addAll(parts.filterNot { it in requiredSingleSelectionActions }) + parts.lastOrNull { it in requiredSingleSelectionActions }?.let(::add) } } var selectedSequence by remember(currentAction) { @@ -925,14 +928,22 @@ private fun TouchActionComboPicker( } items(category.actions) { (actionKey, actionText) -> val isSelected = actionKey in selectedActions - val enabled = isSelected || selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE + val replacesRequiredAction = actionKey in requiredSingleSelectionActions && + selectedActions.any { it in requiredSingleSelectionActions } + val enabled = isSelected || replacesRequiredAction || + selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE Surface( modifier = Modifier .fillMaxWidth() .clickable(enabled = enabled) { if (isSelected) { selectedActions.remove(actionKey) - } else if (selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE) { + } else if (replacesRequiredAction || + selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE + ) { + if (actionKey in requiredSingleSelectionActions) { + selectedActions.removeAll { it in requiredSingleSelectionActions } + } selectedActions.add(actionKey) } }, @@ -963,7 +974,9 @@ private fun TouchActionComboPicker( }, confirmButton = { TextButton( - enabled = selectedActions.isNotEmpty(), + enabled = selectedActions.isNotEmpty() && + (requiredSingleSelectionActions.isEmpty() || + selectedActions.any { it in requiredSingleSelectionActions }), onClick = { onActionSelected( TouchGestureConfig.actionComboOf( 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 bd80767cff..67e57cf244 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 @@ -47,7 +47,7 @@ class PhysicalControllerHandler( // accessed only from main thread (MotionEvent dispatch + Compose lifecycle), no sync needed. private val activeAxisBindings = mutableSetOf() private val activeSequenceTriggerBindings = mutableSetOf() - private val activeSequenceBindings = mutableSetOf() + private val activeSequenceBindings = mutableMapOf() // Tracks whether SHOW_KEYBOARD is currently held, so onShowKeyboard fires once per press (rising edge only) private var showKeyboardPressed = false @@ -493,6 +493,18 @@ class PhysicalControllerHandler( sourceController: ExternalController? = null, ) { if (bindingCombo.isEmpty) return + if (Binding.OPEN_RADIAL_MENU in bindingCombo.bindings) { + handleInputEvent( + Binding.OPEN_RADIAL_MENU, + isActionDown, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + return + } if (bindingCombo.isSequence) { if (isActionDown) { performBindingSequence( @@ -542,6 +554,10 @@ class PhysicalControllerHandler( sourceDeviceId: Int, sourceController: ExternalController?, ) { + val pressDurationMs = minOf( + SEQUENCE_PRESS_MS, + (bindingCombo.sequenceDelayMs - 1).coerceAtLeast(1).toLong(), + ) bindingCombo.bindings.forEachIndexed { index, binding -> sequenceHandler.postDelayed({ handleInputEvent( @@ -553,21 +569,26 @@ class PhysicalControllerHandler( sourceDeviceId, sourceController, ) - activeSequenceBindings.add(binding) + activeSequenceBindings[binding] = (activeSequenceBindings[binding] ?: 0) + 1 sendGamepadState() sequenceHandler.postDelayed({ - handleInputEvent( - binding, - false, - 0f, - fromMotion, - sourceKeyCode, - sourceDeviceId, - sourceController, - ) - activeSequenceBindings.remove(binding) - sendGamepadState() - }, SEQUENCE_PRESS_MS) + val activeCount = activeSequenceBindings[binding] ?: return@postDelayed + if (activeCount > 1) { + activeSequenceBindings[binding] = activeCount - 1 + } else { + activeSequenceBindings.remove(binding) + handleInputEvent( + binding, + false, + 0f, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + sendGamepadState() + } + }, pressDurationMs) }, index * bindingCombo.sequenceDelayMs.toLong()) } } @@ -575,7 +596,7 @@ class PhysicalControllerHandler( private fun cancelActiveSequences() { sequenceHandler.removeCallbacksAndMessages(null) if (activeSequenceBindings.isEmpty()) return - activeSequenceBindings.toList().asReversed().forEach { binding -> + activeSequenceBindings.keys.toList().asReversed().forEach { binding -> handleInputEvent(binding, false, 0f) } activeSequenceBindings.clear() diff --git a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java index bdcb3cf15c..91a5cadd7a 100644 --- a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java +++ b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java @@ -46,7 +46,9 @@ public static Mode fromJsonName(String value) { private BindingCombo(List bindings, Mode mode, int sequenceDelayMs) { this.bindings = Collections.unmodifiableList(bindings); this.mode = bindings.size() > 1 && mode != null ? mode : Mode.SIMULTANEOUS; - this.sequenceDelayMs = normalizeSequenceDelayMs(sequenceDelayMs); + this.sequenceDelayMs = this.mode == Mode.SEQUENCE + ? normalizeSequenceDelayMs(sequenceDelayMs) + : DEFAULT_SEQUENCE_DELAY_MS; } public static BindingCombo none() { @@ -89,8 +91,14 @@ public static BindingCombo fromJsonValue(Object value) { JSONObject object = (JSONObject)value; return fromJsonArray( object.optJSONArray("bindings"), - Mode.fromJsonName(object.optString("mode", Mode.SIMULTANEOUS.getJsonName())), - object.optInt("sequenceDelayMs", object.optInt("delayMs", DEFAULT_SEQUENCE_DELAY_MS)) + Mode.fromJsonName(object.optString( + "mode", + object.optString("bindingMode", Mode.SIMULTANEOUS.getJsonName()))), + object.optInt( + "sequenceDelayMs", + object.optInt( + "bindingDelayMs", + object.optInt("delayMs", DEFAULT_SEQUENCE_DELAY_MS))) ); } if (value instanceof JSONArray) { @@ -170,20 +178,35 @@ public List getBindings() { return bindings; } + public boolean contains(Binding binding) { + return bindings.contains(binding); + } + + public boolean containsGamepadBinding() { + for (Binding binding : bindings) if (binding.isGamepad()) return true; + return false; + } + public boolean isGamepadOnly() { if (bindings.isEmpty()) return false; for (Binding binding : bindings) if (!binding.isGamepad()) return false; return true; } + public void writeToJsonObject(JSONObject object) throws JSONException { + object.put("bindings", toJsonArray()); + if (mode == Mode.SEQUENCE) { + object.put("mode", mode.getJsonName()); + object.put("sequenceDelayMs", sequenceDelayMs); + } + } + public Object toJsonValue() { if (bindings.size() <= 1) return getPrimaryBinding().name(); if (mode == Mode.SEQUENCE) { try { JSONObject object = new JSONObject(); - object.put("mode", mode.getJsonName()); - object.put("sequenceDelayMs", sequenceDelayMs); - object.put("bindings", toJsonArray()); + writeToJsonObject(object); return object; } catch (JSONException e) { return toJsonArray(); diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index 03347a3406..d283f6907f 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -21,6 +21,7 @@ import org.json.JSONObject; import java.util.Arrays; +import java.util.List; import java.util.Locale; public class ControlElement { @@ -267,6 +268,32 @@ private void handleBindingInputEvent(int index, boolean isActionDown, float offs else inputControlsView.handleInputEvent(bindingCombo, isActionDown, offset); } + private void handleSimultaneousBindingMembers( + BindingCombo bindingCombo, + Binding excludedBinding, + boolean isActionDown, + float offset) { + List comboBindings = bindingCombo.getBindings(); + if (isActionDown) { + for (Binding binding : comboBindings) { + if (binding != excludedBinding) inputControlsView.handleInputEvent(binding, true, offset); + } + } + else { + for (int i = comboBindings.size() - 1; i >= 0; i--) { + Binding binding = comboBindings.get(i); + if (binding != excludedBinding) inputControlsView.handleInputEvent(binding, false, offset); + } + } + } + + private Binding findMouseMoveBinding(BindingCombo bindingCombo) { + for (Binding binding : bindingCombo.getBindings()) { + if (binding.isMouseMove()) return binding; + } + return Binding.NONE; + } + public String getShooterMovementType() { return shooterMovementType; } @@ -530,7 +557,10 @@ private String getDisplayText() { if (text.length() > 7) { String[] parts = text.split(" "); StringBuilder sb = new StringBuilder(); - for (String part : parts) sb.append(part.charAt(0)); + for (String part : parts) { + if (part.isEmpty() || part.equals("+") || part.equals("->")) continue; + sb.append(part.charAt(0)); + } return (binding.isMouse() ? "M" : "")+ sb; } else return text; @@ -1109,12 +1139,15 @@ public boolean containsPoint(float x, float y) { } private boolean isKeepButtonPressedAfterMinTime() { - Binding binding = getBindingAt(0); - return !toggleSwitch && (binding == Binding.GAMEPAD_BUTTON_L3 || binding == Binding.GAMEPAD_BUTTON_R3); + BindingCombo bindingCombo = getBindingComboAt(0); + return !toggleSwitch && + (bindingCombo.contains(Binding.GAMEPAD_BUTTON_L3) || bindingCombo.contains(Binding.GAMEPAD_BUTTON_R3)); } private boolean isRadialMenuButton() { - return type == Type.BUTTON && (getBindingAt(0) == Binding.OPEN_RADIAL_MENU || getBindingAt(1) == Binding.OPEN_RADIAL_MENU); + return type == Type.BUTTON && + (getBindingComboAt(0).contains(Binding.OPEN_RADIAL_MENU) || + getBindingComboAt(1).contains(Binding.OPEN_RADIAL_MENU)); } private boolean handleRadialMenuDirectionalMove(int pointerId, boolean[] directionalStates, float x, float y) { @@ -1125,7 +1158,7 @@ private boolean handleRadialMenuDirectionalMove(int pointerId, boolean[] directi } for (byte i = 0; i < directionalStates.length && i < bindings.length; i++) { - if (getBindingAt(i) == Binding.OPEN_RADIAL_MENU && directionalStates[i]) { + if (getBindingComboAt(i).contains(Binding.OPEN_RADIAL_MENU) && directionalStates[i]) { releaseActiveDirectionalStates(); radialMenuTouchActive = true; inputControlsView.handleRadialMenuTouchDown(pointerId, x, y); @@ -1255,8 +1288,26 @@ else if (type == Type.TRACKPAD) { for (byte i = 0; i < 4; i++) { float value = (i == 1 || i == 3 ? deltaX : deltaY); - Binding binding = getBindingAt(i); - if (binding.isGamepad()) { + BindingCombo bindingCombo = getBindingComboAt(i); + Binding binding = bindingCombo.getPrimaryBinding(); + Binding mouseMoveBinding = findMouseMoveBinding(bindingCombo); + if (mouseMoveBinding != Binding.NONE && !bindingCombo.isSequence()) { + if (Math.abs(value) > TouchpadView.CURSOR_ACCELERATION_THRESHOLD) { + value *= TouchpadView.CURSOR_ACCELERATION; + } + if (mouseMoveBinding == Binding.MOUSE_MOVE_LEFT || mouseMoveBinding == Binding.MOUSE_MOVE_RIGHT) { + cursorDx = Mathf.roundPoint(value); + } + else { + cursorDy = Mathf.roundPoint(value); + } + boolean nextState = states[i]; + if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { + handleSimultaneousBindingMembers(bindingCombo, mouseMoveBinding, nextState, value); + } + this.states[i] = nextState; + } + else if (binding.isGamepad()) { if (interpolator == null) interpolator = new CubicBezierInterpolator(); if (Math.abs(value) > TRACKPAD_ACCELERATION_THRESHOLD) value *= STICK_SENSITIVITY; interpolator.set(0.075f, 0.95f, 0.45f, 0.95f); @@ -1265,17 +1316,8 @@ else if (type == Type.TRACKPAD) { this.states[i] = true; } else { - if (Math.abs(value) > TouchpadView.CURSOR_ACCELERATION_THRESHOLD) value *= TouchpadView.CURSOR_ACCELERATION; - if (binding == Binding.MOUSE_MOVE_LEFT || binding == Binding.MOUSE_MOVE_RIGHT) { - cursorDx = Mathf.roundPoint(value); - } - else if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) { - cursorDy = Mathf.roundPoint(value); - } - else { - handleBindingInputEvent(i, states[i], value); - this.states[i] = states[i]; - } + handleBindingInputEvent(i, states[i], value); + this.states[i] = states[i]; } } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index 83c61386eb..37dcb9a1c1 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -284,15 +284,7 @@ public ArrayList loadControllers() { ExternalControllerBinding controllerBinding = new ExternalControllerBinding(); controllerBinding.setKeyCode(controllerBindingJSONObject.getInt("keyCode")); if (controllerBindingJSONObject.has("bindings")) { - BindingCombo.Mode mode = BindingCombo.Mode.fromJsonName( - controllerBindingJSONObject.optString("bindingMode", BindingCombo.Mode.SIMULTANEOUS.getJsonName())); - int delayMs = controllerBindingJSONObject.optInt( - "bindingDelayMs", - BindingCombo.DEFAULT_SEQUENCE_DELAY_MS); - controllerBinding.setBindingCombo(BindingCombo.fromJsonArray( - controllerBindingJSONObject.optJSONArray("bindings"), - mode, - delayMs)); + controllerBinding.setBindingCombo(BindingCombo.fromJsonValue(controllerBindingJSONObject)); } else { controllerBinding.setBinding(Binding.fromString(controllerBindingJSONObject.getString("binding"))); diff --git a/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java b/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java index dc8fefa695..1e5cfbbd3e 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java +++ b/app/src/main/java/com/winlator/inputcontrols/ExternalControllerBinding.java @@ -50,11 +50,7 @@ public JSONObject toJSONObject() { controllerBindingJSONObject.put("keyCode", (int) this.keyCode); controllerBindingJSONObject.put("binding", getBinding().name()); if (!bindingCombo.isSingleBinding()) { - controllerBindingJSONObject.put("bindings", bindingCombo.toJsonArray()); - if (bindingCombo.isSequence()) { - controllerBindingJSONObject.put("bindingMode", bindingCombo.getMode().getJsonName()); - controllerBindingJSONObject.put("bindingDelayMs", bindingCombo.getSequenceDelayMs()); - } + bindingCombo.writeToJsonObject(controllerBindingJSONObject); } return controllerBindingJSONObject; } catch (JSONException e) { diff --git a/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java b/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java index 531a92a454..a009bb8887 100644 --- a/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java +++ b/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java @@ -71,11 +71,7 @@ public JSONObject toJSONObject() throws JSONException { object.put("label", label); object.put("binding", getBinding().name()); if (!bindingCombo.isSingleBinding()) { - object.put("bindings", bindingCombo.toJsonArray()); - if (bindingCombo.isSequence()) { - object.put("bindingMode", bindingCombo.getMode().getJsonName()); - object.put("bindingDelayMs", bindingCombo.getSequenceDelayMs()); - } + bindingCombo.writeToJsonObject(object); } return object; } @@ -84,10 +80,7 @@ public static Slot fromJSONObject(JSONObject object) { Slot slot = new Slot(); slot.setLabel(object.optString("label", "")); if (object.has("bindings")) { - BindingCombo.Mode mode = BindingCombo.Mode.fromJsonName( - object.optString("bindingMode", BindingCombo.Mode.SIMULTANEOUS.getJsonName())); - int delayMs = object.optInt("bindingDelayMs", BindingCombo.DEFAULT_SEQUENCE_DELAY_MS); - slot.setBindingCombo(BindingCombo.fromJsonArray(object.optJSONArray("bindings"), mode, delayMs)); + slot.setBindingCombo(BindingCombo.fromJsonValue(object)); } else { slot.setBinding(Binding.fromString(object.optString("binding", Binding.NONE.name()))); diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index cc70078c8e..49adb6c104 100644 --- a/app/src/main/java/com/winlator/widget/InputControlsView.java +++ b/app/src/main/java/com/winlator/widget/InputControlsView.java @@ -46,7 +46,9 @@ import java.io.InputStream; import java.util.Arrays; import java.util.Collections; +import java.util.EnumMap; import java.util.IdentityHashMap; +import java.util.Map; import java.util.Set; import java.util.Timer; import java.util.TimerTask; @@ -63,6 +65,7 @@ public class InputControlsView extends View { private boolean readyToDraw = false; private boolean moveCursor = false; private final Set activeSequenceCombos = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Map activeSequenceBindings = new EnumMap<>(Binding.class); private int sequenceGeneration = 0; private int snappingSize; private float offsetX; @@ -169,6 +172,7 @@ public void setEditMode(boolean editMode) { } private void cancelTouchRouting() { + cancelBindingSequences(); if (radialMenuTouchActive) handleRadialMenuTouchUp(radialMenuTouchPointerId, false); if (profile != null) { for (ControlElement element : profile.getElements()) element.cancelTouch(); @@ -340,8 +344,6 @@ public synchronized ControlsProfile getProfile() { public synchronized void setProfile(ControlsProfile profile) { cancelTouchRouting(); - sequenceGeneration++; - activeSequenceCombos.clear(); if (profile != null) { this.profile = profile; deselectAllElements(); @@ -1452,17 +1454,53 @@ public void handleInputEvent(BindingCombo bindingCombo, boolean isActionDown, fl private void performBindingSequence(BindingCombo bindingCombo, float offset) { final int generation = sequenceGeneration; + final int pressDurationMs = Math.min( + SEQUENCE_PRESS_MS, + Math.max(1, bindingCombo.getSequenceDelayMs() - 1)); long delay = 0; for (Binding binding : bindingCombo.getBindings()) { postDelayed(() -> { if (generation != sequenceGeneration) return; handleInputEvent(binding, true, offset); - postDelayed(() -> handleInputEvent(binding, false, 0), SEQUENCE_PRESS_MS); + activeSequenceBindings.merge(binding, 1, Integer::sum); + commitGamepadStateIfNeeded(binding); + postDelayed(() -> { + if (generation != sequenceGeneration) return; + releaseActiveSequenceBinding(binding); + }, pressDurationMs); }, delay); delay += bindingCombo.getSequenceDelayMs(); } } + private void releaseActiveSequenceBinding(Binding binding) { + Integer count = activeSequenceBindings.get(binding); + if (count == null) return; + if (count > 1) { + activeSequenceBindings.put(binding, count - 1); + return; + } + activeSequenceBindings.remove(binding); + handleInputEvent(binding, false, 0); + commitGamepadStateIfNeeded(binding); + } + + private void cancelBindingSequences() { + sequenceGeneration++; + activeSequenceCombos.clear(); + if (activeSequenceBindings.isEmpty()) return; + + boolean gamepadStateChanged = false; + Binding[] bindings = activeSequenceBindings.keySet().toArray(new Binding[0]); + for (int i = bindings.length - 1; i >= 0; i--) { + Binding binding = bindings[i]; + handleInputEvent(binding, false, 0); + gamepadStateChanged |= binding.isGamepad(); + } + activeSequenceBindings.clear(); + if (gamepadStateChanged) commitGamepadState(); + } + public void handleInputEvent(Binding binding, boolean isActionDown, float offset) { if (binding == null || binding == Binding.NONE) return; diff --git a/app/src/main/java/com/winlator/widget/TouchpadView.java b/app/src/main/java/com/winlator/widget/TouchpadView.java index 2ab2e7b6bb..3456791a7e 100644 --- a/app/src/main/java/com/winlator/widget/TouchpadView.java +++ b/app/src/main/java/com/winlator/widget/TouchpadView.java @@ -26,7 +26,9 @@ import com.winlator.xserver.XServer; import java.util.ArrayList; -import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; public class TouchpadView extends View implements View.OnCapturedPointerListener { private static final byte MAX_FINGERS = 4; @@ -65,7 +67,7 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener private String delayedPressAction; private Runnable pendingHoldClickRelease; private String pendingHoldClickReleaseAction; - private final ArrayList activeSequenceActions = new ArrayList<>(); + private final Map activeSequenceActions = new LinkedHashMap<>(); private int actionSequenceGeneration; private boolean pressExecuted; @@ -1607,10 +1609,10 @@ private void moveCursorTo(int x, int y) { private boolean injectClick(String action) { if (action == null) return false; - ArrayList parts = actionParts(action); + List parts = TouchGestureConfig.actionParts(action); if (parts.size() > 1) { - if (isActionSequence(action)) { - performActionSequence(parts, actionSequenceDelayMs(action)); + if (TouchGestureConfig.isActionSequence(action)) { + performActionSequence(parts, TouchGestureConfig.actionSequenceDelayMs(action)); return false; } boolean held = false; @@ -1657,8 +1659,8 @@ private boolean injectClick(String action) { private void injectRelease(String action) { if (action == null) return; - if (isActionSequence(action)) return; - ArrayList parts = actionParts(action); + if (TouchGestureConfig.isActionSequence(action)) return; + List parts = TouchGestureConfig.actionParts(action); if (parts.size() > 1) { for (int i = parts.size() - 1; i >= 0; i--) { injectRelease(parts.get(i)); @@ -1716,107 +1718,44 @@ private boolean releaseHoldAction(boolean held, String action) { } private boolean isMouseClickAction(String action) { - String primaryAction = primaryAction(action); - return TouchGestureConfig.ACTION_LEFT_CLICK.equals(primaryAction) - || TouchGestureConfig.ACTION_RIGHT_CLICK.equals(primaryAction) - || TouchGestureConfig.ACTION_MIDDLE_CLICK.equals(primaryAction); - } - - private ArrayList actionParts(String action) { - ArrayList parts = new ArrayList<>(); - if (action == null || action.isEmpty()) return parts; - boolean isSequence = isActionSequence(action); - boolean isCombo = action.startsWith(TouchGestureConfig.ACTION_COMBO_PREFIX); - if (!isSequence && !isCombo) { - parts.add(action); - return parts; - } - String value = action.substring(isSequence - ? TouchGestureConfig.ACTION_SEQUENCE_PREFIX.length() - : TouchGestureConfig.ACTION_COMBO_PREFIX.length()); - if (isSequence) { - int delaySeparator = value.indexOf(':'); - if (delaySeparator > 0 && isDigits(value.substring(0, delaySeparator))) { - value = value.substring(delaySeparator + 1); - } - } - String[] split = value.split("\\|"); - for (String part : split) { - if (part == null || part.isEmpty() || parts.contains(part)) continue; - parts.add(part); - } - if (!isSequence) { - Collections.sort(parts, (a, b) -> Integer.compare(actionComboSortGroup(a), actionComboSortGroup(b))); - } - while (parts.size() > TouchGestureConfig.MAX_ACTION_COMBO_SIZE) parts.remove(parts.size() - 1); - return parts; - } - - private String primaryAction(String action) { - ArrayList parts = actionParts(action); - return parts.isEmpty() ? action : parts.get(parts.size() - 1); - } - - private int actionComboSortGroup(String action) { - if ("key_CTRL_L".equals(action) || "key_CTRL_R".equals(action) - || "key_SHIFT_L".equals(action) || "key_SHIFT_R".equals(action) - || "key_ALT_L".equals(action) || "key_ALT_R".equals(action)) { - return 0; - } - return 1; - } - - private boolean isActionSequence(String action) { - return action != null && action.startsWith(TouchGestureConfig.ACTION_SEQUENCE_PREFIX); - } - - private boolean isDigits(String value) { - if (value == null || value.isEmpty()) return false; - for (int i = 0; i < value.length(); i++) { - if (!Character.isDigit(value.charAt(i))) return false; - } - return true; - } - - private int actionSequenceDelayMs(String action) { - if (!isActionSequence(action)) return TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS; - String value = action.substring(TouchGestureConfig.ACTION_SEQUENCE_PREFIX.length()); - int separator = value.indexOf(':'); - if (separator <= 0 || !isDigits(value.substring(0, separator))) { - return TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS; - } - try { - int delayMs = Integer.parseInt(value.substring(0, separator)); - return Math.max( - TouchGestureConfig.MIN_ACTION_SEQUENCE_DELAY_MS, - Math.min(TouchGestureConfig.MAX_ACTION_SEQUENCE_DELAY_MS, delayMs)); - } catch (NumberFormatException e) { - return TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS; - } + return TouchGestureConfig.containsMouseButtonAction(action); } - private void performActionSequence(ArrayList parts, int sequenceDelayMs) { + private void performActionSequence(List parts, int sequenceDelayMs) { final int generation = actionSequenceGeneration; + final int pressDurationMs = Math.min(SEQUENCE_PRESS_MS, Math.max(1, sequenceDelayMs - 1)); int delay = 0; for (String part : parts) { postDelayed(() -> { if (generation != actionSequenceGeneration) return; if (injectClick(part)) { - activeSequenceActions.add(part); + activeSequenceActions.merge(part, 1, Integer::sum); postDelayed(() -> { if (generation != actionSequenceGeneration) return; - if (activeSequenceActions.remove(part)) injectRelease(part); - }, SEQUENCE_PRESS_MS); + releaseSequenceAction(part); + }, pressDurationMs); } }, delay); delay += sequenceDelayMs; } } + private void releaseSequenceAction(String action) { + Integer count = activeSequenceActions.get(action); + if (count == null) return; + if (count > 1) { + activeSequenceActions.put(action, count - 1); + return; + } + activeSequenceActions.remove(action); + injectRelease(action); + } + private void cancelActionSequences() { actionSequenceGeneration++; - for (int i = activeSequenceActions.size() - 1; i >= 0; i--) { - injectRelease(activeSequenceActions.get(i)); + ArrayList actions = new ArrayList<>(activeSequenceActions.keySet()); + for (int i = actions.size() - 1; i >= 0; i--) { + injectRelease(actions.get(i)); } activeSequenceActions.clear(); } @@ -1883,7 +1822,7 @@ private void performZoomAction(boolean zoomIn) { private void performPanAction(float dx, float dy, String action) { pressPanComboModifiers(action); - action = primaryAction(action); + action = TouchGestureConfig.primaryAction(action); switch (action) { case TouchGestureConfig.PAN_MIDDLE_MOUSE: performMiddleMousePan(dx, dy); @@ -1913,7 +1852,7 @@ private void performPanAction(float dx, float dy, String action) { } private boolean isClickDragAction(String action) { - String primaryAction = primaryAction(action); + String primaryAction = TouchGestureConfig.primaryAction(action); return TouchGestureConfig.PAN_LEFT_CLICK_DRAG.equals(primaryAction) || TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(primaryAction); } @@ -1923,7 +1862,7 @@ private boolean useRelativeMouseDragMovement() { } private Pointer.Button buttonForClickDragAction(String action) { - return TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(primaryAction(action)) + return TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(TouchGestureConfig.primaryAction(action)) ? Pointer.Button.BUTTON_RIGHT : Pointer.Button.BUTTON_LEFT; } @@ -2037,11 +1976,11 @@ private void resetRelativeDragRemainder() { } private void pressPanComboModifiers(String action) { - if (isActionSequence(action)) { + if (TouchGestureConfig.isActionSequence(action)) { releasePanComboModifiers(); return; } - ArrayList parts = actionParts(action); + ArrayList parts = new ArrayList<>(TouchGestureConfig.actionParts(action)); if (parts.size() <= 1) { releasePanComboModifiers(); return; diff --git a/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt b/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt index 839c20825e..8bf828559e 100644 --- a/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt +++ b/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt @@ -78,4 +78,13 @@ class TouchGestureConfigTest { assertEquals(listOf("key_E", TouchGestureConfig.ACTION_LEFT_CLICK), TouchGestureConfig.actionParts(sequence)) assertEquals(TouchGestureConfig.DEFAULT_ACTION_SEQUENCE_DELAY_MS, TouchGestureConfig.actionSequenceDelayMs(sequence)) } + + @Test + fun `mouse action detection does not depend on combo order`() { + val combo = TouchGestureConfig.actionComboOf( + listOf(TouchGestureConfig.ACTION_LEFT_CLICK, "key_E"), + ) + + assertTrue(TouchGestureConfig.containsMouseButtonAction(combo)) + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt index 68293d6879..dce8f6f096 100644 --- a/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt @@ -77,4 +77,31 @@ class BindingComboTest { assertEquals(first, second) assertEquals(first.hashCode(), second.hashCode()) } + + @Test + fun `simultaneous combo ignores sequence delay and round trips equally`() { + val expected = BindingCombo.fromBindings( + listOf(Binding.KEY_CTRL_L, Binding.KEY_1), + BindingCombo.Mode.SIMULTANEOUS, + 800, + ) + + val actual = BindingCombo.fromJsonValue(expected.toJsonValue()) + + assertEquals(BindingCombo.DEFAULT_SEQUENCE_DELAY_MS, expected.sequenceDelayMs) + assertEquals(expected, actual) + } + + @Test + fun `legacy embedded sequence fields still load`() { + val json = JSONObject() + .put("bindings", JSONArray(listOf("KEY_E", "MOUSE_LEFT_BUTTON"))) + .put("bindingMode", "sequence") + .put("bindingDelayMs", 240) + + val combo = BindingCombo.fromJsonValue(json) + + assertEquals(BindingCombo.Mode.SEQUENCE, combo.mode) + assertEquals(240, combo.sequenceDelayMs) + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt index 8affb8ef5a..a58578033f 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt @@ -1,6 +1,8 @@ package com.winlator.inputcontrols import com.winlator.widget.InputControlsView +import com.winlator.widget.TouchpadView +import com.winlator.xserver.XServer import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -8,6 +10,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -127,4 +130,45 @@ class ControlElementDPadRemapTest { events, ) } + + @Test + fun `trackpad mouse movement combo dispatches companion action`() { + val events = mutableListOf>() + val touchpad = mock() + val xServer = mock() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + whenever(view.touchpadView).thenReturn(touchpad) + whenever(view.xServer).thenReturn(xServer) + whenever(touchpad.computeDeltaPoint(any(), any(), any(), any())).thenReturn( + floatArrayOf(0f, 0f), + floatArrayOf(7f, 0f), + floatArrayOf(0f, 0f), + ) + doAnswer { invocation -> + events += invocation.getArgument(0) to invocation.getArgument(1) + null + }.whenever(view).handleInputEvent(any(), any(), any()) + + val element = ControlElement(view).apply { + setType(ControlElement.Type.TRACKPAD) + setX(100) + setY(100) + setBindingComboAt( + 1, + BindingCombo.fromBindings(listOf(Binding.KEY_CTRL_L, Binding.MOUSE_MOVE_RIGHT)), + ) + } + + assertTrue(element.handleTouchDown(1, 100f, 100f)) + assertTrue(element.handleTouchMove(1, 107f, 100f)) + assertEquals(listOf(Binding.KEY_CTRL_L to true), events) + verify(xServer).injectPointerMoveDelta(any(), any()) + + assertTrue(element.handleTouchMove(1, 107f, 100f)) + assertEquals( + listOf(Binding.KEY_CTRL_L to true, Binding.KEY_CTRL_L to false), + events, + ) + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementLookThroughTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementLookThroughTest.kt index 1d62be5fe0..44aeedf68a 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementLookThroughTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementLookThroughTest.kt @@ -65,4 +65,19 @@ class ControlElementLookThroughTest { assertFalse(element.isLookThrough) assertFalse(element.isShooterLookThrough) } + + @Test + fun `radial menu anywhere in combo disables look-through`() { + val element = ControlElement(null).apply { + setType(ControlElement.Type.BUTTON) + lookThroughSetting = true + setBindingComboAt( + 0, + BindingCombo.fromBindings(listOf(Binding.OPEN_RADIAL_MENU, Binding.KEY_E)), + ) + } + + assertFalse(element.isLookThrough) + assertFalse(element.isShooterLookThrough) + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt b/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt index 59b3d15cc8..23b938f701 100644 --- a/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt @@ -43,8 +43,23 @@ class RadialMenuTest { val restored = RadialMenu.Slot.fromJSONObject(json) assertEquals(JSONArray(listOf("KEY_E", "MOUSE_LEFT_BUTTON")).toString(), json.getJSONArray("bindings").toString()) - assertEquals("sequence", json.getString("bindingMode")) - assertEquals(240, json.getInt("bindingDelayMs")) + assertEquals("sequence", json.getString("mode")) + assertEquals(240, json.getInt("sequenceDelayMs")) assertEquals(expected, restored.bindingCombo) } + + @Test + fun `legacy radial sequence fields still load`() { + val slot = RadialMenu.Slot.fromJSONObject( + JSONObject() + .put("label", "Interact") + .put("binding", "MOUSE_LEFT_BUTTON") + .put("bindings", JSONArray(listOf("KEY_E", "MOUSE_LEFT_BUTTON"))) + .put("bindingMode", "sequence") + .put("bindingDelayMs", 240), + ) + + assertEquals(BindingCombo.Mode.SEQUENCE, slot.bindingCombo.mode) + assertEquals(240, slot.bindingCombo.sequenceDelayMs) + } } From 85bdb077edb11d567ae77c3f779cf5fe03edad0b Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Tue, 18 Aug 2026 23:50:00 -0500 Subject: [PATCH 5/8] Fix sequence mouse movement cleanup --- .../xserver/PhysicalControllerHandler.kt | 75 ++++++++++++++----- .../winlator/inputcontrols/BindingCombo.java | 6 +- .../inputcontrols/ControlElement.java | 6 +- .../xserver/PhysicalControllerHandlerTest.kt | 60 +++++++++++++++ .../inputcontrols/BindingComboTest.kt | 7 ++ .../ControlElementDPadRemapTest.kt | 26 ++++++- 6 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt 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 67e57cf244..b785aeee80 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 @@ -30,6 +30,12 @@ class PhysicalControllerHandler( private val onRadialMenuButtonStateChanged: ((Boolean, Boolean) -> Unit)? = null, private val onRadialMenuVectorChanged: ((Float, Float) -> Unit)? = null, ) { + private data class MouseMoveSource( + val deviceId: Int, + val keyCode: Int, + val binding: Binding, + ) + companion object { private const val SCROLL_REPEAT_INTERVAL_MS = 90L private const val UNKNOWN_DEVICE_ID = -1 @@ -38,6 +44,7 @@ class PhysicalControllerHandler( private val TAG = "gncontrol" private val mouseMoveOffset = PointF(0f, 0f) + private val mouseMoveContributions = mutableMapOf() private val sequenceHandler = Handler(Looper.getMainLooper()) private var mouseMoveTimer: Timer? = null private var scrollRepeatTimer: Timer? = null @@ -79,6 +86,7 @@ class PhysicalControllerHandler( fun setProfile(profile: ControlsProfile?) { releaseActiveAxes() cancelActiveSequences() + clearMouseMoveContributions() clearScrollRepeats() closeRadialMenuIfOpen(commit = false) activeSequenceTriggerBindings.clear() @@ -89,7 +97,6 @@ class PhysicalControllerHandler( if (profile == null) { mouseMoveTimer?.cancel() mouseMoveTimer = null - mouseMoveOffset.set(0f, 0f) } } @@ -101,7 +108,7 @@ class PhysicalControllerHandler( cancelActiveSequences() mouseMoveTimer?.cancel() mouseMoveTimer = null - mouseMoveOffset.set(0f, 0f) + clearMouseMoveContributions() clearScrollRepeats() activeSequenceTriggerBindings.clear() showKeyboardPressed = false @@ -288,6 +295,49 @@ class PhysicalControllerHandler( } } + private fun updateMouseMoveContribution( + binding: Binding, + isActionDown: Boolean, + offset: Float, + sourceKeyCode: Int, + sourceDeviceId: Int, + ) { + if (isActionDown) { + val contribution = if (offset != 0f) { + offset + } else if (binding == Binding.MOUSE_MOVE_LEFT || binding == Binding.MOUSE_MOVE_UP) { + -1f + } else { + 1f + } + mouseMoveContributions[MouseMoveSource(sourceDeviceId, sourceKeyCode, binding)] = contribution + createMouseMoveTimer() + } else { + mouseMoveContributions.keys.removeAll { source -> + source.binding == binding && + (sourceKeyCode == KeyEvent.KEYCODE_UNKNOWN || source.keyCode == sourceKeyCode) && + (sourceDeviceId == UNKNOWN_DEVICE_ID || source.deviceId == sourceDeviceId) + } + } + recalculateMouseMoveOffset() + } + + private fun recalculateMouseMoveOffset() { + mouseMoveOffset.set(0f, 0f) + mouseMoveContributions.forEach { (source, contribution) -> + if (source.binding == Binding.MOUSE_MOVE_LEFT || source.binding == Binding.MOUSE_MOVE_RIGHT) { + mouseMoveOffset.x += contribution + } else { + mouseMoveOffset.y += contribution + } + } + } + + private fun clearMouseMoveContributions() { + mouseMoveContributions.clear() + mouseMoveOffset.set(0f, 0f) + } + private fun handleScrollBinding(binding: Binding, isActionDown: Boolean): Boolean { if (binding != Binding.MOUSE_SCROLL_UP && binding != Binding.MOUSE_SCROLL_DOWN) { return false @@ -348,9 +398,6 @@ class PhysicalControllerHandler( * Extracted from InputControlsView.processJoystickInput() */ private fun processJoystickInput(controller: ExternalController, deviceId: Int) { - // Reset mouse movement offset at the start - contributions will be added during processing - mouseMoveOffset.set(0f, 0f) - val axes = intArrayOf( MotionEvent.AXIS_X, MotionEvent.AXIS_Y, @@ -724,21 +771,9 @@ class PhysicalControllerHandler( showKeyboardPressed = false } } else if (binding == Binding.MOUSE_MOVE_LEFT || binding == Binding.MOUSE_MOVE_RIGHT) { - // Handle horizontal mouse movement - ADD contribution from this input - if (isActionDown) { - val contribution = if (offset != 0f) offset else if (binding == Binding.MOUSE_MOVE_LEFT) -1f else 1f - mouseMoveOffset.x += contribution - createMouseMoveTimer() - } - // Don't reset when isActionDown=false - mouseMoveOffset is reset at the start of processJoystickInput + updateMouseMoveContribution(binding, isActionDown, offset, sourceKeyCode, sourceDeviceId) } else if (binding == Binding.MOUSE_MOVE_DOWN || binding == Binding.MOUSE_MOVE_UP) { - // Handle vertical mouse movement - ADD contribution from this input - if (isActionDown) { - val contribution = if (offset != 0f) offset else if (binding == Binding.MOUSE_MOVE_UP) -1f else 1f - mouseMoveOffset.y += contribution - createMouseMoveTimer() - } - // Don't reset when isActionDown=false - mouseMoveOffset is reset at the start of processJoystickInput + updateMouseMoveContribution(binding, isActionDown, offset, sourceKeyCode, sourceDeviceId) } else if (handleScrollBinding(binding, isActionDown)) { // Mouse wheel events are pulses, not held button state. } else { @@ -775,7 +810,7 @@ class PhysicalControllerHandler( releaseActiveAxes(exceptKeyCode) neutralizeTrigger(controller, KeyEvent.KEYCODE_BUTTON_L2, exceptKeyCode) neutralizeTrigger(controller, KeyEvent.KEYCODE_BUTTON_R2, exceptKeyCode) - mouseMoveOffset.set(0f, 0f) + clearMouseMoveContributions() clearScrollRepeats() } diff --git a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java index 91a5cadd7a..7d9686b798 100644 --- a/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java +++ b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java @@ -89,8 +89,12 @@ public static BindingCombo fromBindings(List values, Mode mode, int seq public static BindingCombo fromJsonValue(Object value) { if (value instanceof JSONObject) { JSONObject object = (JSONObject)value; + JSONArray bindings = object.optJSONArray("bindings"); + if (bindings == null) { + return of(Binding.fromString(object.optString("binding", Binding.NONE.name()))); + } return fromJsonArray( - object.optJSONArray("bindings"), + bindings, Mode.fromJsonName(object.optString( "mode", object.optString("bindingMode", Mode.SIMULTANEOUS.getJsonName()))), diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index d283f6907f..39e0046ba1 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -1303,7 +1303,11 @@ else if (type == Type.TRACKPAD) { } boolean nextState = states[i]; if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { - handleSimultaneousBindingMembers(bindingCombo, mouseMoveBinding, nextState, value); + handleSimultaneousBindingMembers( + bindingCombo, + mouseMoveBinding, + nextState, + Mathf.clamp(value, -1, 1)); } this.states[i] = nextState; } diff --git a/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt new file mode 100644 index 0000000000..a220d01cf9 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt @@ -0,0 +1,60 @@ +package app.gamenative.ui.screen.xserver + +import android.graphics.PointF +import android.os.Looper +import android.view.KeyEvent +import com.winlator.inputcontrols.Binding +import com.winlator.inputcontrols.BindingCombo +import com.winlator.inputcontrols.ControlsProfile +import com.winlator.inputcontrols.ExternalController +import com.winlator.inputcontrols.ExternalControllerBinding +import com.winlator.xserver.XServer +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.TimeUnit.MILLISECONDS + +@RunWith(RobolectricTestRunner::class) +class PhysicalControllerHandlerTest { + @Test + fun `sequence releases mouse movement without another motion event`() { + val keyCode = KeyEvent.KEYCODE_BUTTON_A + val controllerBinding = ExternalControllerBinding().apply { + setKeyCode(keyCode) + setBindingCombo( + BindingCombo.fromBindings( + listOf(Binding.MOUSE_MOVE_RIGHT, Binding.KEY_E), + BindingCombo.Mode.SEQUENCE, + 150, + ), + ) + } + val controller = mock() + whenever(controller.getControllerBinding(keyCode)).thenReturn(controllerBinding) + val profile = mock() + whenever(profile.getController(KeyEvent(KeyEvent.ACTION_DOWN, keyCode).deviceId)).thenReturn(controller) + whenever(profile.cursorSpeed).thenReturn(1f) + val handler = PhysicalControllerHandler(profile, mock()) + + try { + handler.onKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + shadowOf(Looper.getMainLooper()).idleFor(1, MILLISECONDS) + assertEquals(1f, mouseMoveOffset(handler).x, 0f) + + shadowOf(Looper.getMainLooper()).idleFor(80, MILLISECONDS) + assertEquals(0f, mouseMoveOffset(handler).x, 0f) + } finally { + handler.cleanup() + } + } + + private fun mouseMoveOffset(handler: PhysicalControllerHandler): PointF { + val field = PhysicalControllerHandler::class.java.getDeclaredField("mouseMoveOffset") + field.isAccessible = true + return field.get(handler) as PointF + } +} diff --git a/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt index dce8f6f096..e4a7d4b117 100644 --- a/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt @@ -104,4 +104,11 @@ class BindingComboTest { assertEquals(BindingCombo.Mode.SEQUENCE, combo.mode) assertEquals(240, combo.sequenceDelayMs) } + + @Test + fun `legacy binding object restores single binding`() { + val combo = BindingCombo.fromJsonValue(JSONObject().put("binding", "KEY_E")) + + assertEquals(BindingCombo.of(Binding.KEY_E), combo) + } } diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt index a58578033f..fa79d4b6da 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt @@ -134,6 +134,7 @@ class ControlElementDPadRemapTest { @Test fun `trackpad mouse movement combo dispatches companion action`() { val events = mutableListOf>() + val offsets = mutableListOf>() val touchpad = mock() val xServer = mock() val view = mock() @@ -146,7 +147,9 @@ class ControlElementDPadRemapTest { floatArrayOf(0f, 0f), ) doAnswer { invocation -> - events += invocation.getArgument(0) to invocation.getArgument(1) + val binding = invocation.getArgument(0) + events += binding to invocation.getArgument(1) + offsets += binding to invocation.getArgument(2) null }.whenever(view).handleInputEvent(any(), any(), any()) @@ -156,18 +159,33 @@ class ControlElementDPadRemapTest { setY(100) setBindingComboAt( 1, - BindingCombo.fromBindings(listOf(Binding.KEY_CTRL_L, Binding.MOUSE_MOVE_RIGHT)), + BindingCombo.fromBindings( + listOf( + Binding.KEY_CTRL_L, + Binding.GAMEPAD_LEFT_THUMB_RIGHT, + Binding.MOUSE_MOVE_RIGHT, + ), + ), ) } assertTrue(element.handleTouchDown(1, 100f, 100f)) assertTrue(element.handleTouchMove(1, 107f, 100f)) - assertEquals(listOf(Binding.KEY_CTRL_L to true), events) + assertEquals( + listOf(Binding.KEY_CTRL_L to true, Binding.GAMEPAD_LEFT_THUMB_RIGHT to true), + events, + ) + assertEquals(1f, offsets.first { it.first == Binding.GAMEPAD_LEFT_THUMB_RIGHT }.second, 0f) verify(xServer).injectPointerMoveDelta(any(), any()) assertTrue(element.handleTouchMove(1, 107f, 100f)) assertEquals( - listOf(Binding.KEY_CTRL_L to true, Binding.KEY_CTRL_L to false), + listOf( + Binding.KEY_CTRL_L to true, + Binding.GAMEPAD_LEFT_THUMB_RIGHT to true, + Binding.GAMEPAD_LEFT_THUMB_RIGHT to false, + Binding.KEY_CTRL_L to false, + ), events, ) } From f1dec51054cdfd8c4f0a4723717c5b3b3e547ee9 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Wed, 19 Aug 2026 00:00:18 -0500 Subject: [PATCH 6/8] Fix mixed analog combo routing --- .../xserver/PhysicalControllerHandler.kt | 14 ++-- .../inputcontrols/ControlElement.java | 7 +- .../xserver/PhysicalControllerHandlerTest.kt | 15 +++-- .../ControlElementDPadRemapTest.kt | 65 +++++++++++++++++++ 4 files changed, 86 insertions(+), 15 deletions(-) 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 b785aeee80..0cc0a6a92f 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 @@ -92,12 +92,6 @@ class PhysicalControllerHandler( activeSequenceTriggerBindings.clear() this.profile = profile Log.d(TAG, "PhysicalControllerHandler: Profile set to ${profile?.name}") - - // Cancel mouse movement timer if profile is null - if (profile == null) { - mouseMoveTimer?.cancel() - mouseMoveTimer = null - } } /** @@ -106,8 +100,6 @@ class PhysicalControllerHandler( fun cleanup() { releaseActiveAxes() cancelActiveSequences() - mouseMoveTimer?.cancel() - mouseMoveTimer = null clearMouseMoveContributions() clearScrollRepeats() activeSequenceTriggerBindings.clear() @@ -331,11 +323,17 @@ class PhysicalControllerHandler( mouseMoveOffset.y += contribution } } + if (mouseMoveContributions.isEmpty()) { + mouseMoveTimer?.cancel() + mouseMoveTimer = null + } } private fun clearMouseMoveContributions() { mouseMoveContributions.clear() mouseMoveOffset.set(0f, 0f) + mouseMoveTimer?.cancel() + mouseMoveTimer = null } private fun handleScrollBinding(binding: Binding, isActionDown: Boolean): Boolean { diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index 39e0046ba1..db7404c383 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -1265,8 +1265,9 @@ public boolean handleTouchMove(int pointerId, float x, float y) { for (byte i = 0; i < 4; i++) { float value = i == 1 || i == 3 ? deltaX : deltaY; - Binding binding = getBindingAt(i); - if (binding.isGamepad()) { + BindingCombo bindingCombo = getBindingComboAt(i); + Binding binding = bindingCombo.getPrimaryBinding(); + if (bindingCombo.containsGamepadBinding()) { value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * STICK_SENSITIVITY, -1, 1); handleBindingInputEvent(i, true, value); this.states[i] = true; @@ -1311,7 +1312,7 @@ else if (type == Type.TRACKPAD) { } this.states[i] = nextState; } - else if (binding.isGamepad()) { + else if (bindingCombo.containsGamepadBinding()) { if (interpolator == null) interpolator = new CubicBezierInterpolator(); if (Math.abs(value) > TRACKPAD_ACCELERATION_THRESHOLD) value *= STICK_SENSITIVITY; interpolator.set(0.075f, 0.95f, 0.45f, 0.95f); diff --git a/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt index a220d01cf9..05eb3d95bd 100644 --- a/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt +++ b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt @@ -10,6 +10,7 @@ import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.ExternalControllerBinding import com.winlator.xserver.XServer import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -23,13 +24,14 @@ class PhysicalControllerHandlerTest { @Test fun `sequence releases mouse movement without another motion event`() { val keyCode = KeyEvent.KEYCODE_BUTTON_A + val sequenceDelayMs = 150 val controllerBinding = ExternalControllerBinding().apply { setKeyCode(keyCode) setBindingCombo( BindingCombo.fromBindings( listOf(Binding.MOUSE_MOVE_RIGHT, Binding.KEY_E), BindingCombo.Mode.SEQUENCE, - 150, + sequenceDelayMs, ), ) } @@ -45,16 +47,21 @@ class PhysicalControllerHandlerTest { shadowOf(Looper.getMainLooper()).idleFor(1, MILLISECONDS) assertEquals(1f, mouseMoveOffset(handler).x, 0f) - shadowOf(Looper.getMainLooper()).idleFor(80, MILLISECONDS) + shadowOf(Looper.getMainLooper()).idleFor(sequenceDelayMs.toLong() - 1, MILLISECONDS) assertEquals(0f, mouseMoveOffset(handler).x, 0f) + assertNull(privateField(handler, "mouseMoveTimer")) } finally { handler.cleanup() } } private fun mouseMoveOffset(handler: PhysicalControllerHandler): PointF { - val field = PhysicalControllerHandler::class.java.getDeclaredField("mouseMoveOffset") + return privateField(handler, "mouseMoveOffset") as PointF + } + + private fun privateField(handler: PhysicalControllerHandler, name: String): Any? { + val field = PhysicalControllerHandler::class.java.getDeclaredField(name) field.isAccessible = true - return field.get(handler) as PointF + return field.get(handler) } } diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt index fa79d4b6da..a9da2c8e30 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt @@ -18,6 +18,11 @@ import org.robolectric.RobolectricTestRunner class ControlElementDPadRemapTest { private data class Fixture(val element: ControlElement, val state: GamepadState) + private fun mixedGamepadBindingOrders() = listOf( + listOf(Binding.GAMEPAD_LEFT_THUMB_RIGHT, Binding.KEY_E), + listOf(Binding.KEY_E, Binding.GAMEPAD_LEFT_THUMB_RIGHT), + ) + private fun fixture(): Fixture { val state = GamepadState() val view = mock() @@ -189,4 +194,64 @@ class ControlElementDPadRemapTest { events, ) } + + @Test + fun `stick mixed combo scaling does not depend on binding order`() { + val scaledOffsets = mixedGamepadBindingOrders().map { bindings -> + val offsets = mutableListOf() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + doAnswer { invocation -> + offsets += invocation.getArgument(2) + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.STICK) + setX(100) + setY(100) + setBindingComboAt(1, BindingCombo.fromBindings(bindings)) + } + + assertTrue(element.handleTouchDown(1, 115f, 100f)) + offsets.single() + } + + assertEquals(scaledOffsets.first(), scaledOffsets.last(), 0f) + assertTrue(scaledOffsets.first() in 0f..1f) + } + + @Test + fun `trackpad mixed combo interpolation does not depend on binding order`() { + val interpolatedOffsets = mutableListOf() + mixedGamepadBindingOrders().forEach { bindings -> + val offsets = mutableListOf() + val touchpad = mock() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + whenever(view.touchpadView).thenReturn(touchpad) + whenever(view.xServer).thenReturn(mock()) + whenever(touchpad.computeDeltaPoint(any(), any(), any(), any())).thenReturn( + floatArrayOf(0f, 0f), + floatArrayOf(2f, 0f), + ) + doAnswer { invocation -> + offsets += invocation.getArgument(2) + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.TRACKPAD) + setX(100) + setY(100) + setBindingComboAt(1, BindingCombo.fromBindings(bindings)) + } + + assertTrue(element.handleTouchDown(1, 100f, 100f)) + offsets.clear() + assertTrue(element.handleTouchMove(1, 102f, 100f)) + interpolatedOffsets += offsets.single() + } + + assertEquals(interpolatedOffsets.first(), interpolatedOffsets.last(), 0f) + assertTrue(interpolatedOffsets.first() in 0f..1f) + } } From 3d297e03af38b83c6b0f391359573b786fbca321 Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Wed, 19 Aug 2026 00:21:01 -0500 Subject: [PATCH 7/8] Fix mixed gamepad combo routing --- .../com/winlator/inputcontrols/Binding.java | 5 + .../inputcontrols/ControlElement.java | 58 +++-- .../ControlElementDPadRemapTest.kt | 202 ++++++++++++++---- 3 files changed, 205 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/winlator/inputcontrols/Binding.java b/app/src/main/java/com/winlator/inputcontrols/Binding.java index 29eb906915..02f5c3d2b7 100644 --- a/app/src/main/java/com/winlator/inputcontrols/Binding.java +++ b/app/src/main/java/com/winlator/inputcontrols/Binding.java @@ -202,6 +202,11 @@ public boolean isGamepad() { return name().startsWith("GAMEPAD_"); } + public boolean isGamepadAxis() { + return ordinal() >= GAMEPAD_LEFT_THUMB_UP.ordinal() && + ordinal() <= GAMEPAD_RIGHT_THUMB_LEFT.ordinal(); + } + public boolean isExtra() { return this == OPEN_NAVIGATION_MENU || this == SHOW_KEYBOARD || this == ALT_ENTER || this == OPEN_RADIAL_MENU; } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index db7404c383..d120a219c8 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -294,6 +294,13 @@ private Binding findMouseMoveBinding(BindingCombo bindingCombo) { return Binding.NONE; } + private Binding findGamepadAxisBinding(BindingCombo bindingCombo) { + for (Binding binding : bindingCombo.getBindings()) { + if (binding.isGamepadAxis()) return binding; + } + return Binding.NONE; + } + public String getShooterMovementType() { return shooterMovementType; } @@ -1267,14 +1274,25 @@ public boolean handleTouchMove(int pointerId, float x, float y) { float value = i == 1 || i == 3 ? deltaX : deltaY; BindingCombo bindingCombo = getBindingComboAt(i); Binding binding = bindingCombo.getPrimaryBinding(); - if (bindingCombo.containsGamepadBinding()) { + Binding gamepadAxisBinding = findGamepadAxisBinding(bindingCombo); + if (gamepadAxisBinding != Binding.NONE && !bindingCombo.isSequence()) { value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * STICK_SENSITIVITY, -1, 1); - handleBindingInputEvent(i, true, value); - this.states[i] = true; + inputControlsView.handleInputEvent(gamepadAxisBinding, true, value); + boolean nextState = states[i]; + if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { + handleSimultaneousBindingMembers( + bindingCombo, + gamepadAxisBinding, + nextState, + value); + } + this.states[i] = bindingCombo.isSingleBinding() || nextState; } else { boolean state = binding.isMouseMove() ? (states[i] || states[(i+2)%4]) : states[i]; - handleBindingInputEvent(i, state, value); + if (binding.isMouseMove() || this.states[i] != state) { + handleBindingInputEvent(i, state, value); + } this.states[i] = state; } } @@ -1312,17 +1330,29 @@ else if (type == Type.TRACKPAD) { } this.states[i] = nextState; } - else if (bindingCombo.containsGamepadBinding()) { - if (interpolator == null) interpolator = new CubicBezierInterpolator(); - if (Math.abs(value) > TRACKPAD_ACCELERATION_THRESHOLD) value *= STICK_SENSITIVITY; - interpolator.set(0.075f, 0.95f, 0.45f, 0.95f); - float interpolatedValue = interpolator.getInterpolation(Math.min(1.0f, Math.abs(value / TRACKPAD_MAX_SPEED))); - handleBindingInputEvent(i, true, Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1)); - this.states[i] = true; - } else { - handleBindingInputEvent(i, states[i], value); - this.states[i] = states[i]; + Binding gamepadAxisBinding = findGamepadAxisBinding(bindingCombo); + if (gamepadAxisBinding != Binding.NONE && !bindingCombo.isSequence()) { + if (interpolator == null) interpolator = new CubicBezierInterpolator(); + if (Math.abs(value) > TRACKPAD_ACCELERATION_THRESHOLD) value *= STICK_SENSITIVITY; + interpolator.set(0.075f, 0.95f, 0.45f, 0.95f); + float interpolatedValue = interpolator.getInterpolation(Math.min(1.0f, Math.abs(value / TRACKPAD_MAX_SPEED))); + float gamepadOffset = Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1); + inputControlsView.handleInputEvent(gamepadAxisBinding, true, gamepadOffset); + boolean nextState = states[i]; + if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { + handleSimultaneousBindingMembers( + bindingCombo, + gamepadAxisBinding, + nextState, + gamepadOffset); + } + this.states[i] = bindingCombo.isSingleBinding() || nextState; + } + else if (this.states[i] != states[i]) { + handleBindingInputEvent(i, states[i], value); + this.states[i] = states[i]; + } } } diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt index a9da2c8e30..48bdf20052 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt @@ -23,6 +23,61 @@ class ControlElementDPadRemapTest { listOf(Binding.KEY_E, Binding.GAMEPAD_LEFT_THUMB_RIGHT), ) + private fun stickRightOffset(bindings: List): Float { + val offsets = mutableListOf() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + doAnswer { invocation -> + if (invocation.getArgument(0) == Binding.GAMEPAD_LEFT_THUMB_RIGHT && + invocation.getArgument(1) + ) { + offsets += invocation.getArgument(2) + } + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.STICK) + setX(100) + setY(100) + setBindingComboAt(1, BindingCombo.fromBindings(bindings)) + } + + assertTrue(element.handleTouchDown(1, 115f, 100f)) + return offsets.last() + } + + private fun trackpadRightOffset(bindings: List): Float { + val offsets = mutableListOf() + val touchpad = mock() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + whenever(view.touchpadView).thenReturn(touchpad) + whenever(view.xServer).thenReturn(mock()) + whenever(touchpad.computeDeltaPoint(any(), any(), any(), any())).thenReturn( + floatArrayOf(0f, 0f), + floatArrayOf(2f, 0f), + ) + doAnswer { invocation -> + if (invocation.getArgument(0) == Binding.GAMEPAD_LEFT_THUMB_RIGHT && + invocation.getArgument(1) + ) { + offsets += invocation.getArgument(2) + } + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.TRACKPAD) + setX(100) + setY(100) + setBindingComboAt(1, BindingCombo.fromBindings(bindings)) + } + + assertTrue(element.handleTouchDown(1, 100f, 100f)) + offsets.clear() + assertTrue(element.handleTouchMove(1, 102f, 100f)) + return offsets.last() + } + private fun fixture(): Fixture { val state = GamepadState() val view = mock() @@ -197,61 +252,116 @@ class ControlElementDPadRemapTest { @Test fun `stick mixed combo scaling does not depend on binding order`() { - val scaledOffsets = mixedGamepadBindingOrders().map { bindings -> - val offsets = mutableListOf() - val view = mock() - whenever(view.snappingSize).thenReturn(10) - doAnswer { invocation -> - offsets += invocation.getArgument(2) - null - }.whenever(view).handleInputEvent(any(), any(), any()) - val element = ControlElement(view).apply { - setType(ControlElement.Type.STICK) - setX(100) - setY(100) - setBindingComboAt(1, BindingCombo.fromBindings(bindings)) - } + val baseline = stickRightOffset(listOf(Binding.GAMEPAD_LEFT_THUMB_RIGHT)) + val scaledOffsets = mixedGamepadBindingOrders().map(::stickRightOffset) + + scaledOffsets.forEach { assertEquals(baseline, it, 0f) } + assertTrue(baseline in 0f..1f) + } + + @Test + fun `trackpad mixed combo interpolation does not depend on binding order`() { + val baseline = trackpadRightOffset(listOf(Binding.GAMEPAD_LEFT_THUMB_RIGHT)) + val interpolatedOffsets = mixedGamepadBindingOrders().map(::trackpadRightOffset) + + interpolatedOffsets.forEach { assertEquals(baseline, it, 0f) } + assertTrue(baseline in 0f..1f) + } - assertTrue(element.handleTouchDown(1, 115f, 100f)) - offsets.single() + @Test + fun `digital gamepad combo waits for stick direction transition`() { + val states = mutableListOf() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + doAnswer { invocation -> + states += invocation.getArgument(1) + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.STICK) + setX(100) + setY(100) + setBindingComboAt( + 1, + BindingCombo.fromBindings(listOf(Binding.GAMEPAD_BUTTON_A, Binding.KEY_E)), + ) } - assertEquals(scaledOffsets.first(), scaledOffsets.last(), 0f) - assertTrue(scaledOffsets.first() in 0f..1f) + assertTrue(element.handleTouchDown(1, 100f, 100f)) + assertTrue(states.isEmpty()) + assertTrue(element.handleTouchMove(1, 115f, 100f)) + assertTrue(element.handleTouchMove(1, 116f, 100f)) + assertTrue(element.handleTouchMove(1, 100f, 100f)) + assertEquals(listOf(true, false), states) } @Test - fun `trackpad mixed combo interpolation does not depend on binding order`() { - val interpolatedOffsets = mutableListOf() - mixedGamepadBindingOrders().forEach { bindings -> - val offsets = mutableListOf() - val touchpad = mock() - val view = mock() - whenever(view.snappingSize).thenReturn(10) - whenever(view.touchpadView).thenReturn(touchpad) - whenever(view.xServer).thenReturn(mock()) - whenever(touchpad.computeDeltaPoint(any(), any(), any(), any())).thenReturn( - floatArrayOf(0f, 0f), - floatArrayOf(2f, 0f), + fun `stick sequence fires once per directional activation`() { + val states = mutableListOf() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + doAnswer { invocation -> + states += invocation.getArgument(1) + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.STICK) + setX(100) + setY(100) + setBindingComboAt( + 1, + BindingCombo.fromBindings( + listOf(Binding.KEY_E, Binding.GAMEPAD_BUTTON_A), + BindingCombo.Mode.SEQUENCE, + ), ) - doAnswer { invocation -> - offsets += invocation.getArgument(2) - null - }.whenever(view).handleInputEvent(any(), any(), any()) - val element = ControlElement(view).apply { - setType(ControlElement.Type.TRACKPAD) - setX(100) - setY(100) - setBindingComboAt(1, BindingCombo.fromBindings(bindings)) - } + } + + assertTrue(element.handleTouchDown(1, 115f, 100f)) + assertTrue(element.handleTouchMove(1, 116f, 100f)) + assertTrue(element.handleTouchMove(1, 100f, 100f)) + assertTrue(element.handleTouchMove(1, 115f, 100f)) + assertEquals(listOf(true, false, true), states) + } - assertTrue(element.handleTouchDown(1, 100f, 100f)) - offsets.clear() - assertTrue(element.handleTouchMove(1, 102f, 100f)) - interpolatedOffsets += offsets.single() + @Test + fun `trackpad sequence fires once per directional activation`() { + val states = mutableListOf() + val touchpad = mock() + val view = mock() + whenever(view.snappingSize).thenReturn(10) + whenever(view.touchpadView).thenReturn(touchpad) + whenever(view.xServer).thenReturn(mock()) + whenever(touchpad.computeDeltaPoint(any(), any(), any(), any())).thenReturn( + floatArrayOf(0f, 0f), + floatArrayOf(2f, 0f), + floatArrayOf(2f, 0f), + floatArrayOf(0f, 0f), + floatArrayOf(2f, 0f), + ) + doAnswer { invocation -> + states += invocation.getArgument(1) + null + }.whenever(view).handleInputEvent(any(), any(), any()) + val element = ControlElement(view).apply { + setType(ControlElement.Type.TRACKPAD) + setX(100) + setY(100) + setBindingComboAt( + 1, + BindingCombo.fromBindings( + listOf(Binding.KEY_E, Binding.GAMEPAD_BUTTON_A), + BindingCombo.Mode.SEQUENCE, + ), + ) } - assertEquals(interpolatedOffsets.first(), interpolatedOffsets.last(), 0f) - assertTrue(interpolatedOffsets.first() in 0f..1f) + assertTrue(element.handleTouchDown(1, 100f, 100f)) + assertTrue(states.isEmpty()) + assertTrue(element.handleTouchMove(1, 102f, 100f)) + assertTrue(element.handleTouchMove(1, 104f, 100f)) + assertTrue(element.handleTouchMove(1, 104f, 100f)) + assertTrue(element.handleTouchMove(1, 106f, 100f)) + assertEquals(listOf(true, false, true), states) } } From 2de06f8e243b6d20654eb0190dc0a3c6e0a3afdc Mon Sep 17 00:00:00 2001 From: Nightwalker743 Date: Wed, 19 Aug 2026 00:30:30 -0500 Subject: [PATCH 8/8] Release sub-threshold gamepad axes --- .../inputcontrols/ControlElement.java | 22 +++-- .../ControlElementDPadRemapTest.kt | 87 ++++++++++++++----- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java index d120a219c8..17d10078e3 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlElement.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlElement.java @@ -86,6 +86,7 @@ public static String[] names() { private boolean currentPointerActivatedButtonBindings = false; private final Rect boundingBox = new Rect(); private boolean[] states = new boolean[4]; + private boolean[] gamepadAxisActive = new boolean[4]; private boolean radialMenuTouchActive = false; private boolean boundingBoxNeedsUpdate = true; private String text = ""; @@ -180,6 +181,7 @@ public void setBindingCount(int bindingCount) { bindings = new BindingCombo[bindingCount]; setBinding(Binding.NONE); states = new boolean[bindingCount]; + gamepadAxisActive = new boolean[bindingCount]; boundingBoxNeedsUpdate = true; } @@ -243,6 +245,7 @@ public void setBindingComboAt(int index, BindingCombo binding) { bindings = Arrays.copyOf(bindings, index+1); Arrays.fill(bindings, oldLength, bindings.length, BindingCombo.none()); states = new boolean[bindings.length]; + gamepadAxisActive = new boolean[bindings.length]; boundingBoxNeedsUpdate = true; } bindings[index] = binding != null ? binding : BindingCombo.none(); @@ -1180,7 +1183,14 @@ private boolean handleRadialMenuDirectionalMove(int pointerId, boolean[] directi private void releaseActiveDirectionalStates() { for (byte i = 0; i < states.length && i < bindings.length; i++) { if (states[i]) handleBindingInputEvent(i, false); + else if (gamepadAxisActive[i]) { + Binding gamepadAxisBinding = findGamepadAxisBinding(getBindingComboAt(i)); + if (gamepadAxisBinding != Binding.NONE) { + inputControlsView.handleInputEvent(gamepadAxisBinding, false, 0); + } + } states[i] = false; + gamepadAxisActive[i] = false; } } @@ -1278,6 +1288,7 @@ public boolean handleTouchMove(int pointerId, float x, float y) { if (gamepadAxisBinding != Binding.NONE && !bindingCombo.isSequence()) { value = Mathf.clamp(Math.max(0, Math.abs(value) - 0.01f) * Mathf.sign(value) * STICK_SENSITIVITY, -1, 1); inputControlsView.handleInputEvent(gamepadAxisBinding, true, value); + gamepadAxisActive[i] = value != 0; boolean nextState = states[i]; if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { handleSimultaneousBindingMembers( @@ -1339,6 +1350,7 @@ else if (type == Type.TRACKPAD) { float interpolatedValue = interpolator.getInterpolation(Math.min(1.0f, Math.abs(value / TRACKPAD_MAX_SPEED))); float gamepadOffset = Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1); inputControlsView.handleInputEvent(gamepadAxisBinding, true, gamepadOffset); + gamepadAxisActive[i] = gamepadOffset != 0; boolean nextState = states[i]; if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { handleSimultaneousBindingMembers( @@ -1433,10 +1445,7 @@ else if (!toggleSwitch || selected) { currentPointerActivatedButtonBindings = false; } else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { - for (byte i = 0; i < states.length; i++) { - if (states[i]) handleBindingInputEvent(i, false); - states[i] = false; - } + releaseActiveDirectionalStates(); if (type == Type.RANGE_BUTTON) { scroller.handleTouchUp(); @@ -1477,10 +1486,7 @@ else if (type == Type.BUTTON) { touchTime = null; } else if (type == Type.RANGE_BUTTON || type == Type.D_PAD || type == Type.STICK || type == Type.TRACKPAD) { - for (byte i = 0; i < states.length; i++) { - if (states[i]) handleBindingInputEvent(i, false); - states[i] = false; - } + releaseActiveDirectionalStates(); if (type == Type.RANGE_BUTTON) scroller.handleTouchUp(); currentPosition = null; } diff --git a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt index 48bdf20052..a4960024c9 100644 --- a/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt +++ b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt @@ -23,18 +23,21 @@ class ControlElementDPadRemapTest { listOf(Binding.KEY_E, Binding.GAMEPAD_LEFT_THUMB_RIGHT), ) - private fun stickRightOffset(bindings: List): Float { - val offsets = mutableListOf() - val view = mock() - whenever(view.snappingSize).thenReturn(10) + private fun captureRightAxisEvents(view: InputControlsView): MutableList> { + val events = mutableListOf>() doAnswer { invocation -> - if (invocation.getArgument(0) == Binding.GAMEPAD_LEFT_THUMB_RIGHT && - invocation.getArgument(1) - ) { - offsets += invocation.getArgument(2) + if (invocation.getArgument(0) == Binding.GAMEPAD_LEFT_THUMB_RIGHT) { + events += invocation.getArgument(1) to invocation.getArgument(2) } null }.whenever(view).handleInputEvent(any(), any(), any()) + return events + } + + private fun stickRightOffset(bindings: List): Float { + val view = mock() + val events = captureRightAxisEvents(view) + whenever(view.snappingSize).thenReturn(10) val element = ControlElement(view).apply { setType(ControlElement.Type.STICK) setX(100) @@ -43,13 +46,13 @@ class ControlElementDPadRemapTest { } assertTrue(element.handleTouchDown(1, 115f, 100f)) - return offsets.last() + return events.last { it.first }.second } private fun trackpadRightOffset(bindings: List): Float { - val offsets = mutableListOf() val touchpad = mock() val view = mock() + val events = captureRightAxisEvents(view) whenever(view.snappingSize).thenReturn(10) whenever(view.touchpadView).thenReturn(touchpad) whenever(view.xServer).thenReturn(mock()) @@ -57,14 +60,6 @@ class ControlElementDPadRemapTest { floatArrayOf(0f, 0f), floatArrayOf(2f, 0f), ) - doAnswer { invocation -> - if (invocation.getArgument(0) == Binding.GAMEPAD_LEFT_THUMB_RIGHT && - invocation.getArgument(1) - ) { - offsets += invocation.getArgument(2) - } - null - }.whenever(view).handleInputEvent(any(), any(), any()) val element = ControlElement(view).apply { setType(ControlElement.Type.TRACKPAD) setX(100) @@ -73,9 +68,9 @@ class ControlElementDPadRemapTest { } assertTrue(element.handleTouchDown(1, 100f, 100f)) - offsets.clear() + events.clear() assertTrue(element.handleTouchMove(1, 102f, 100f)) - return offsets.last() + return events.last { it.first }.second } private fun fixture(): Fixture { @@ -268,6 +263,58 @@ class ControlElementDPadRemapTest { assertTrue(baseline in 0f..1f) } + @Test + fun `stick releases sub-threshold mixed axis on touch up`() { + val view = mock() + val events = captureRightAxisEvents(view) + whenever(view.snappingSize).thenReturn(10) + val element = ControlElement(view).apply { + setType(ControlElement.Type.STICK) + setX(100) + setY(100) + setBindingComboAt( + 1, + BindingCombo.fromBindings(listOf(Binding.GAMEPAD_LEFT_THUMB_RIGHT, Binding.KEY_E)), + ) + } + + assertTrue(element.handleTouchDown(1, 102f, 100f)) + assertTrue(events.last().first) + assertTrue(events.last().second > 0f) + assertTrue(element.handleTouchUp(1)) + assertEquals(false, events.last().first) + assertEquals(0f, events.last().second, 0f) + } + + @Test + fun `trackpad releases sub-threshold mixed axis on cancellation`() { + val touchpad = mock() + val view = mock() + val events = captureRightAxisEvents(view) + whenever(view.snappingSize).thenReturn(10) + whenever(view.touchpadView).thenReturn(touchpad) + whenever(view.xServer).thenReturn(mock()) + whenever(touchpad.computeDeltaPoint(any(), any(), any(), any())).thenReturn( + floatArrayOf(0.5f, 0f), + ) + val element = ControlElement(view).apply { + setType(ControlElement.Type.TRACKPAD) + setX(100) + setY(100) + setBindingComboAt( + 1, + BindingCombo.fromBindings(listOf(Binding.GAMEPAD_LEFT_THUMB_RIGHT, Binding.KEY_E)), + ) + } + + assertTrue(element.handleTouchDown(1, 100f, 100f)) + assertTrue(events.last().first) + assertTrue(events.last().second > 0f) + assertTrue(element.cancelTouch()) + assertEquals(false, events.last().first) + assertEquals(0f, events.last().second, 0f) + } + @Test fun `digital gamepad combo waits for stick direction transition`() { val states = mutableListOf()