diff --git a/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt b/app/src/main/java/app/gamenative/data/TouchGestureConfig.kt index ad25a7f8ba..108e15f85d 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,92 @@ 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) + } + } + } + + @JvmStatic + 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) } + } + + @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) + 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 + } + + @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) + 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..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 @@ -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, stringResource(app.gamenative.R.string.save)) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Default.Close, stringResource(app.gamenative.R.string.close)) + } } } @@ -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 @@ -390,12 +455,20 @@ fun ControllerBindingDialog( } } else { filteredBindings.forEach { binding -> + val isNone = binding == Binding.NONE BindingOption( binding = binding, - isSelected = binding == currentBinding, + isSelected = if (isNone) selectedBindings.isEmpty() else binding in selectedBindings, + enabled = isNone || binding in selectedBindings || selectedBindings.size < BindingCombo.MAX_BINDINGS, onClick = { - Log.d("ControllerBindingDialog", "Binding selected for $buttonName: ${binding.name}") - onBindingSelected(binding) + if (isNone) { + selectedBindings.clear() + } else 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 +484,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 +509,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..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 @@ -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,17 +97,17 @@ 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") 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.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,21 +1056,21 @@ 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(" ") val sb = StringBuilder() for (part in parts) { - if (part.isNotEmpty()) sb.append(part[0]) + if (part.isNotEmpty() && part != "+" && part != "->") 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..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 @@ -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() } } @@ -188,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.setBinding(binding) - ctrl.addControllerBinding(newBinding) - } - } + restoreOriginalBindings() onDismiss() }, properties = DialogProperties( @@ -224,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.setBinding(binding) - ctrl.addControllerBinding(newBinding) - } - } + restoreOriginalBindings() onDismiss() }) { - Icon(Icons.Default.Close, null) + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) } }, actions = { @@ -255,13 +244,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 +271,7 @@ internal fun PhysicalControllerConfigSection( if (binding != null) { val newBinding = ExternalControllerBinding() newBinding.setKeyCode(keyCode) - newBinding.setBinding(binding) + newBinding.setBindingCombo(binding) ctrl.addControllerBinding(newBinding) } } @@ -537,9 +526,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 +575,7 @@ private fun CategoryButton( private fun ControllerBindingItem( label: String, keyCode: Int, - workingBindings: Map, + workingBindings: Map, onClick: () -> Unit ) { val binding = workingBindings[keyCode] @@ -629,7 +620,7 @@ private fun PhysicalControlPresets( leftStickAxes: List, rightStickAxes: List, dpadButtons: List, - workingBindings: MutableMap, + workingBindings: MutableMap, onPresetsApplied: () -> Unit = {} ) { Card( @@ -793,7 +784,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 +851,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 +891,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.containsGamepadBinding()) { hasGamepadBindings = true break } 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/component/dialog/SettingsDialogBlocks.kt b/app/src/main/java/app/gamenative/ui/component/dialog/SettingsDialogBlocks.kt index 7b7cbbe361..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 @@ -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,50 @@ fun DelayTextField( LaunchedEffect(clampedValue, value) { if (value != clampedValue) onValueChange(clampedValue) } + 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 || clampedValue != lastSubmittedValue) { + text = clampedValue.toString() + lastSubmittedValue = clampedValue + } + } 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 + normalizedDraftValue(filtered)?.let { nextValue -> + lastSubmittedValue = nextValue + if (nextValue != value) onValueChange(nextValue) } - 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 = normalizedDraftValue(text) ?: clampedValue + text = nextValue.toString() + lastSubmittedValue = nextValue + 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..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 @@ -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,32 @@ 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 + return TouchGestureConfig.containsMouseButtonAction(action) +} + +@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 +714,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 +730,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 +745,78 @@ 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, + requiredSingleSelectionActions = PAN_ACTIONS.toSet(), + 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, + requiredSingleSelectionActions: Set = emptySet(), + actionLabel: @Composable (String) -> String, + onActionSelected: (String) -> Unit, ) { var showDialog by remember { mutableStateOf(false) } - val label = panActionLabel(currentAction) Surface( modifier = Modifier @@ -733,9 +836,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 +849,149 @@ private fun PanActionPicker( } if (showDialog) { + val selectedActions = remember(currentAction) { + mutableStateListOf().apply { + val parts = TouchGestureConfig.actionParts(currentAction) + addAll(parts.filterNot { it in requiredSingleSelectionActions }) + parts.lastOrNull { it in requiredSingleSelectionActions }?.let(::add) + } + } + 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 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 (replacesRequiredAction || + selectedActions.size < TouchGestureConfig.MAX_ACTION_COMBO_SIZE + ) { + if (actionKey in requiredSingleSelectionActions) { + selectedActions.removeAll { it in requiredSingleSelectionActions } + } + 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() && + (requiredSingleSelectionActions.isEmpty() || + selectedActions.any { it in requiredSingleSelectionActions }), + 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..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 @@ -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 @@ -27,13 +30,22 @@ 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 + private const val SEQUENCE_PRESS_MS = 80L } 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 private val scrollRepeatLock = Any() @@ -41,6 +53,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 = mutableMapOf() // Tracks whether SHOW_KEYBOARD is currently held, so onShowKeyboard fires once per press (rising edge only) private var showKeyboardPressed = false @@ -54,25 +68,30 @@ 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() + clearMouseMoveContributions() clearScrollRepeats() closeRadialMenuIfOpen(commit = false) + 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 - mouseMoveOffset.set(0f, 0f) - } } /** @@ -80,10 +99,10 @@ class PhysicalControllerHandler( */ fun cleanup() { releaseActiveAxes() - mouseMoveTimer?.cancel() - mouseMoveTimer = null - mouseMoveOffset.set(0f, 0f) + cancelActiveSequences() + clearMouseMoveContributions() clearScrollRepeats() + activeSequenceTriggerBindings.clear() showKeyboardPressed = false closeRadialMenuIfOpen(commit = false) } @@ -98,12 +117,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 +142,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 +212,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 +231,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, @@ -264,6 +287,55 @@ 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 + } + } + 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 { if (binding != Binding.MOUSE_SCROLL_UP && binding != Binding.MOUSE_SCROLL_DOWN) { return false @@ -324,9 +396,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, @@ -352,25 +421,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 +455,7 @@ class PhysicalControllerHandler( if (activeAxisBindings.remove(posKeyCode)) { controller.getControllerBinding(posKeyCode)?.let { handleInputEvent( - it.binding, + it.bindingCombo, false, 0f, fromMotion = true, @@ -398,7 +468,7 @@ class PhysicalControllerHandler( if (activeAxisBindings.remove(negKeyCode)) { controller.getControllerBinding(negKeyCode)?.let { handleInputEvent( - it.binding, + it.bindingCombo, false, 0f, fromMotion = true, @@ -412,12 +482,172 @@ 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 (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( + 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?, + ) { + val pressDurationMs = minOf( + SEQUENCE_PRESS_MS, + (bindingCombo.sequenceDelayMs - 1).coerceAtLeast(1).toLong(), + ) + bindingCombo.bindings.forEachIndexed { index, binding -> + sequenceHandler.postDelayed({ + handleInputEvent( + binding, + true, + offset, + fromMotion, + sourceKeyCode, + sourceDeviceId, + sourceController, + ) + activeSequenceBindings[binding] = (activeSequenceBindings[binding] ?: 0) + 1 + sendGamepadState() + sequenceHandler.postDelayed({ + 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()) + } + } + + private fun cancelActiveSequences() { + sequenceHandler.removeCallbacksAndMessages(null) + if (activeSequenceBindings.isEmpty()) return + activeSequenceBindings.keys.toList().asReversed().forEach { binding -> + handleInputEvent(binding, false, 0f) + } + activeSequenceBindings.clear() + sendGamepadState() + } + private fun handleInputEvent( binding: Binding, isActionDown: Boolean, @@ -460,12 +690,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) } @@ -537,21 +769,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 { @@ -588,7 +808,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() } @@ -601,9 +821,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/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/RadialMenuCoordinator.kt index f4076210ba..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 @@ -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 @@ -41,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, @@ -90,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( @@ -103,6 +108,7 @@ class RadialMenuCoordinator( fun detach() { close(commit = false) + cancelBindingDispatches() inputControlsView?.setRadialMenuListener(null) touchpadView?.setOpenRadialMenuCallback(null) settingsDialog?.dismiss() @@ -113,6 +119,7 @@ class RadialMenuCoordinator( } fun bindInputControlsView(view: InputControlsView?) { + cancelBindingDispatches() inputControlsView?.setRadialMenuListener(null) inputControlsView = view view?.let { overlayView.setControlsStyle(it.primaryColor, it.secondaryColor) } @@ -132,6 +139,7 @@ class RadialMenuCoordinator( } fun setProfile(profile: ControlsProfile?) { + cancelBindingDispatches() activeControlsProfile = profile } @@ -346,6 +354,7 @@ class RadialMenuCoordinator( } private fun applyProfile(profile: ControlsProfile?) { + cancelBindingDispatches() activeControlsProfile = profile if (profile != null) { if (inputControlsView?.profile != null) { @@ -403,14 +412,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,13 +483,58 @@ 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 + cancelBindingDispatches() + + if (bindingCombo.isSequence) { + dispatchBindingSequence(bindingCombo) + return + } + + val generation = bindingDispatchGeneration + for (binding in bindingCombo.bindings) { + pressDispatchedBinding(binding) + } host.postDelayed({ + if (generation != bindingDispatchGeneration) return@postDelayed + bindingCombo.bindings.asReversed().forEach { binding -> + releaseDispatchedBinding(binding) + } + }, BINDING_PRESS_MS) + } + + private fun dispatchBindingSequence(bindingCombo: BindingCombo) { + val generation = bindingDispatchGeneration + bindingCombo.bindings.forEachIndexed { index, binding -> + host.postDelayed({ + if (generation != bindingDispatchGeneration) return@postDelayed + pressDispatchedBinding(binding) + host.postDelayed({ + 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) - }, 70L) + } + activeDispatchedBindings.clear() } private fun bindingOffset(binding: Binding): Float { 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/BindingCombo.java b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java new file mode 100644 index 0000000000..7d9686b798 --- /dev/null +++ b/app/src/main/java/com/winlator/inputcontrols/BindingCombo.java @@ -0,0 +1,254 @@ +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 = this.mode == Mode.SEQUENCE + ? normalizeSequenceDelayMs(sequenceDelayMs) + : DEFAULT_SEQUENCE_DELAY_MS; + } + + 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; + JSONArray bindings = object.optJSONArray("bindings"); + if (bindings == null) { + return of(Binding.fromString(object.optString("binding", Binding.NONE.name()))); + } + return fromJsonArray( + bindings, + 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) { + 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 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(); + writeToJsonObject(object); + 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..17d10078e3 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 { @@ -74,7 +75,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; @@ -85,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 = ""; @@ -119,22 +121,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,9 +178,10 @@ public int getBindingCount() { } public void setBindingCount(int bindingCount) { - bindings = new Binding[bindingCount]; + bindings = new BindingCombo[bindingCount]; setBinding(Binding.NONE); states = new boolean[bindingCount]; + gamepadAxisActive = new boolean[bindingCount]; boundingBoxNeedsUpdate = true; } @@ -225,22 +228,80 @@ 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]; + gamepadAxisActive = 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)); + } + + 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); + } + + 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; + } + + private Binding findGamepadAxisBinding(BindingCombo bindingCombo) { + for (Binding binding : bindingCombo.getBindings()) { + if (binding.isGamepadAxis()) return binding; + } + return Binding.NONE; } public String getShooterMovementType() { @@ -500,12 +561,16 @@ 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(); - 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; @@ -1042,7 +1107,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)); @@ -1084,12 +1149,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) { @@ -1100,7 +1168,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 (getBindingComboAt(i).contains(Binding.OPEN_RADIAL_MENU) && directionalStates[i]) { releaseActiveDirectionalStates(); radialMenuTouchActive = true; inputControlsView.handleRadialMenuTouchDown(pointerId, x, y); @@ -1114,8 +1182,15 @@ 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]) 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; } } @@ -1133,8 +1208,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); + handleBindingInputEvent(0, true); + handleBindingInputEvent(1, true); } inputControlsView.invalidate(); return true; @@ -1207,15 +1282,28 @@ 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(); + 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); - inputControlsView.handleInputEvent(binding, true, value); - this.states[i] = true; + inputControlsView.handleInputEvent(gamepadAxisBinding, true, value); + gamepadAxisActive[i] = value != 0; + 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]; - inputControlsView.handleInputEvent(binding, state, value); + if (binding.isMouseMove() || this.states[i] != state) { + handleBindingInputEvent(i, state, value); + } this.states[i] = state; } } @@ -1230,25 +1318,51 @@ 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()) { - 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))); - inputControlsView.handleInputEvent(binding, true, Mathf.clamp(interpolatedValue * Mathf.sign(value), -1, 1)); - 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) { + 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 if (binding == Binding.MOUSE_MOVE_UP || binding == Binding.MOUSE_MOVE_DOWN) { + else { cursorDy = Mathf.roundPoint(value); } - else { - inputControlsView.handleInputEvent(binding, states[i], value); + boolean nextState = states[i]; + if (!bindingCombo.isSingleBinding() && this.states[i] != nextState) { + handleSimultaneousBindingMembers( + bindingCombo, + mouseMoveBinding, + nextState, + Mathf.clamp(value, -1, 1)); + } + this.states[i] = nextState; + } + else { + 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); + gamepadAxisActive[i] = gamepadOffset != 0; + 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]; } } @@ -1266,13 +1380,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); + handleBindingInputEvent(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]) handleBindingInputEvent(i, true, value); this.states[i] = states[i]; } @@ -1310,15 +1424,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); + handleBindingInputEvent(0, false); + handleBindingInputEvent(1, false); } touchTime = null; inputControlsView.invalidate(); } else if (!toggleSwitch || selected) { - inputControlsView.handleInputEvent(getBindingAt(0), false); - inputControlsView.handleInputEvent(getBindingAt(1), false); + handleBindingInputEvent(0, false); + handleBindingInputEvent(1, false); } if (toggleSwitch) { @@ -1331,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]) inputControlsView.handleInputEvent(getBindingAt(i), false); - states[i] = false; - } + releaseActiveDirectionalStates(); if (type == Type.RANGE_BUTTON) { scroller.handleTouchUp(); @@ -1368,17 +1479,14 @@ 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); - states[i] = false; - } + releaseActiveDirectionalStates(); if (type == Type.RANGE_BUTTON) scroller.handleTouchUp(); currentPosition = null; } diff --git a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java index d8b1af3c85..37dcb9a1c1 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControlsProfile.java @@ -283,7 +283,12 @@ 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")) { + controllerBinding.setBindingCombo(BindingCombo.fromJsonValue(controllerBindingJSONObject)); + } + else { + controllerBinding.setBinding(Binding.fromString(controllerBindingJSONObject.getString("binding"))); + } controller.addControllerBinding(controllerBinding); } controllers.add(controller); @@ -365,9 +370,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..1e5cfbbd3e 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,29 @@ 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()) { + bindingCombo.writeToJsonObject(controllerBindingJSONObject); + } return controllerBindingJSONObject; } catch (JSONException e) { return null; diff --git a/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java b/app/src/main/java/com/winlator/inputcontrols/RadialMenu.java index 8b162e989d..a009bb8887 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,48 @@ 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()) { + bindingCombo.writeToJsonObject(object); + } 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")) { + slot.setBindingCombo(BindingCombo.fromJsonValue(object)); + } + else { + slot.setBinding(Binding.fromString(object.optString("binding", Binding.NONE.name()))); + } return slot; } } diff --git a/app/src/main/java/com/winlator/widget/InputControlsView.java b/app/src/main/java/com/winlator/widget/InputControlsView.java index 765a9de8bc..49adb6c104 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,18 @@ import java.io.IOException; 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; 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 +64,9 @@ 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 final Map activeSequenceBindings = new EnumMap<>(Binding.class); + private int sequenceGeneration = 0; private int snappingSize; private float offsetX; private float offsetY; @@ -162,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(); @@ -431,13 +442,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 +1207,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 +1407,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 +1423,84 @@ 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; + 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); + 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 23768aec08..3456791a7e 100644 --- a/app/src/main/java/com/winlator/widget/TouchpadView.java +++ b/app/src/main/java/com/winlator/widget/TouchpadView.java @@ -26,6 +26,9 @@ import com.winlator.xserver.XServer; import java.util.ArrayList; +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; @@ -54,6 +57,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 +67,8 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener private String delayedPressAction; private Runnable pendingHoldClickRelease; private String pendingHoldClickReleaseAction; + private final Map activeSequenceActions = new LinkedHashMap<>(); + private int actionSequenceGeneration; private boolean pressExecuted; private final boolean capturePointerOnExternalMouse; @@ -90,6 +96,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 +1013,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 +1317,7 @@ private void handleTsCancel() { cancelLongPressTimer(); cancelTwoFingerHoldTimer(); cancelThreeFingerHoldTimer(); + cancelActionSequences(); stopGestureRefresh(); flushPendingHoldClickRelease(); // A tap's release is normally scheduled via delayedPress so we can @@ -1601,6 +1609,18 @@ private void moveCursorTo(int x, int y) { private boolean injectClick(String action) { if (action == null) return false; + List parts = TouchGestureConfig.actionParts(action); + if (parts.size() > 1) { + if (TouchGestureConfig.isActionSequence(action)) { + performActionSequence(parts, TouchGestureConfig.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 +1659,14 @@ private boolean injectClick(String action) { private void injectRelease(String action) { if (action == null) return; + 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)); + } + return; + } if (TouchGestureConfig.ACTION_SHOW_KEYBOARD.equals(action)) { return; // One-shot action, no release needed } @@ -1690,9 +1718,46 @@ 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); + return TouchGestureConfig.containsMouseButtonAction(action); + } + + 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.merge(part, 1, Integer::sum); + postDelayed(() -> { + if (generation != actionSequenceGeneration) return; + 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++; + ArrayList actions = new ArrayList<>(activeSequenceActions.keySet()); + for (int i = actions.size() - 1; i >= 0; i--) { + injectRelease(actions.get(i)); + } + activeSequenceActions.clear(); } private XKeycode actionToKeycode(String action) { @@ -1756,6 +1821,8 @@ private void performZoomAction(boolean zoomIn) { } private void performPanAction(float dx, float dy, String action) { + pressPanComboModifiers(action); + action = TouchGestureConfig.primaryAction(action); switch (action) { case TouchGestureConfig.PAN_MIDDLE_MOUSE: performMiddleMousePan(dx, dy); @@ -1785,8 +1852,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 = TouchGestureConfig.primaryAction(action); + return TouchGestureConfig.PAN_LEFT_CLICK_DRAG.equals(primaryAction) + || TouchGestureConfig.PAN_RIGHT_CLICK_DRAG.equals(primaryAction); } private boolean useRelativeMouseDragMovement() { @@ -1794,12 +1862,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(TouchGestureConfig.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 +1886,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 +1967,7 @@ private void releaseAllDragButtons() { releaseTwoFingerMiddleButton(); releaseClickDragButtons(); resetRelativeDragRemainder(); + releasePanComboModifiers(); } private void resetRelativeDragRemainder() { @@ -1901,6 +1975,34 @@ private void resetRelativeDragRemainder() { relativeDragRemainderY = 0; } + private void pressPanComboModifiers(String action) { + if (TouchGestureConfig.isActionSequence(action)) { + releasePanComboModifiers(); + return; + } + ArrayList parts = new ArrayList<>(TouchGestureConfig.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..8bf828559e 100644 --- a/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt +++ b/app/src/test/java/app/gamenative/data/TouchGestureConfigTest.kt @@ -46,4 +46,45 @@ 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)) + } + + @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/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..05eb3d95bd --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandlerTest.kt @@ -0,0 +1,67 @@ +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.Assert.assertNull +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 sequenceDelayMs = 150 + val controllerBinding = ExternalControllerBinding().apply { + setKeyCode(keyCode) + setBindingCombo( + BindingCombo.fromBindings( + listOf(Binding.MOUSE_MOVE_RIGHT, Binding.KEY_E), + BindingCombo.Mode.SEQUENCE, + sequenceDelayMs, + ), + ) + } + 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(sequenceDelayMs.toLong() - 1, MILLISECONDS) + assertEquals(0f, mouseMoveOffset(handler).x, 0f) + assertNull(privateField(handler, "mouseMoveTimer")) + } finally { + handler.cleanup() + } + } + + private fun mouseMoveOffset(handler: PhysicalControllerHandler): PointF { + 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) + } +} 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..e4a7d4b117 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/BindingComboTest.kt @@ -0,0 +1,114 @@ +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()) + } + + @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) + } + + @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/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/ControlElementDPadRemapTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControlElementDPadRemapTest.kt index 8affb8ef5a..a4960024c9 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 @@ -15,6 +18,61 @@ 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 captureRightAxisEvents(view: InputControlsView): MutableList> { + val events = mutableListOf>() + doAnswer { invocation -> + 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) + setY(100) + setBindingComboAt(1, BindingCombo.fromBindings(bindings)) + } + + assertTrue(element.handleTouchDown(1, 115f, 100f)) + return events.last { it.first }.second + } + + private fun trackpadRightOffset(bindings: List): Float { + 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(0f, 0f), + floatArrayOf(2f, 0f), + ) + val element = ControlElement(view).apply { + setType(ControlElement.Type.TRACKPAD) + setX(100) + setY(100) + setBindingComboAt(1, BindingCombo.fromBindings(bindings)) + } + + assertTrue(element.handleTouchDown(1, 100f, 100f)) + events.clear() + assertTrue(element.handleTouchMove(1, 102f, 100f)) + return events.last { it.first }.second + } + private fun fixture(): Fixture { val state = GamepadState() val view = mock() @@ -127,4 +185,230 @@ class ControlElementDPadRemapTest { events, ) } + + @Test + fun `trackpad mouse movement combo dispatches companion action`() { + val events = mutableListOf>() + val offsets = 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 -> + val binding = invocation.getArgument(0) + events += binding to invocation.getArgument(1) + offsets += binding to 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( + 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, 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.GAMEPAD_LEFT_THUMB_RIGHT to true, + Binding.GAMEPAD_LEFT_THUMB_RIGHT to false, + Binding.KEY_CTRL_L to false, + ), + events, + ) + } + + @Test + fun `stick mixed combo scaling does not depend on binding order`() { + 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) + } + + @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() + 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)), + ) + } + + 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 `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, + ), + ) + } + + 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) + } + + @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, + ), + ) + } + + 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) + } } 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 new file mode 100644 index 0000000000..23b938f701 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/RadialMenuTest.kt @@ -0,0 +1,65 @@ +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("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) + } +}