Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions app/src/main/java/app/gamenative/data/TouchGestureConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -317,5 +324,92 @@ data class TouchGestureConfig(
ZOOM_PLUS_MINUS,
ZOOM_PAGE_UP_DOWN,
)

fun actionComboOf(
actions: List<String>,
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<String> {
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
Comment thread
Nightwalker743 marked this conversation as resolved.
}
Comment thread
Nightwalker743 marked this conversation as resolved.

@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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Int?>(0) } // 0 = Keyboard, 1 = Mouse, 2 = Gamepad, 3 = Extra, null = All
var isSearchExpanded by remember { mutableStateOf(false) }
val selectedBindings = remember(currentBindingCombo, currentBinding) {
mutableStateListOf<Binding>().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() }
Expand Down Expand Up @@ -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))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
}
)
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Expand All @@ -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
Expand All @@ -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
)
Expand Down
Loading
Loading