Android: make the stick-target and macro pickers pad-navigable

Both were AlertDialogs, and both are lists the pad has to walk, so they
were the worst of the set: not merely a button you could not press, but
a whole list of choices with no way to reach any of them.

Each becomes an inline panel with every row registered, so the selection
walks the list and A picks. Deliberately a plain Column with
verticalScroll rather than a LazyColumn: the nav registry only knows
about rows that are actually composed, so a lazy list would hide
everything past the viewport from the pad while looking correct on
touch. Both lists are bounded -- a fixed button set plus the hotkey
enum, and the macro target set -- so composing all of it costs nothing.

The macro rows also take Left/Right to clear and set, which is what
every other toggle in the app does. A row should not behave differently
because it happens to be inside a panel.

Test both paths here especially. These are the only converted sites
where the list is long enough for held-direction repeat to matter, and
the two ladders repeat through different mechanisms -- a timer on the
motion path, repeat-count on the key path.
This commit is contained in:
Brian Degenhardt
2026-08-02 20:49:36 -07:00
parent b27ba2e209
commit 8c50d9de6b
@@ -1,6 +1,10 @@
package com.armsx2.ui.settings
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Surface
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
@@ -14,12 +18,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -975,46 +977,84 @@ private fun StickTargetPickerDialog(
onPick: (Int?) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
textContentColor = MaterialTheme.colorScheme.onSurface,
title = { Text(title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) },
text = {
Column(Modifier.verticalScroll(remember { ScrollState(0) })) {
Text(
str("pad.stickTarget.intro"),
color = Color(0xFFBBBBBB), fontSize = 15.sp,
)
val layer = "stick-target-picker"
// A plain scrolling Column, NOT a LazyColumn: the nav registry only knows about rows that
// are actually composed, so a lazy list hides everything past the viewport from the pad.
// This list is bounded (a fixed button set plus the hotkey enum), so composing it all is fine.
com.armsx2.ui.common.PadModal(key = layer, onDismiss = onDismiss) {
Surface(
modifier = Modifier
.padding(24.dp)
.widthIn(max = 420.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
tonalElevation = 6.dp,
) {
Column(Modifier.padding(20.dp)) {
Text(title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
StickPickItem(str("pad.stickTarget.analogDefault"), current in 110..123) { onPick(null) }
Spacer(Modifier.height(6.dp))
Text(str("pad.stickTarget.ps2Buttons"), color = Colors.pasx2_blue, fontSize = 14.sp, fontWeight = FontWeight.Bold)
ControllerMappings.stickTargets.forEach { t ->
StickPickItem(t.label, current == t.code) { onPick(t.code) }
Column(
Modifier
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
str("pad.stickTarget.intro"),
color = Color(0xFFBBBBBB), fontSize = 15.sp,
)
Spacer(Modifier.height(8.dp))
StickPickItem(str("pad.stickTarget.analogDefault"), current in 110..123, "$layer.analog") { onPick(null) }
Spacer(Modifier.height(6.dp))
Text(str("pad.stickTarget.ps2Buttons"), color = Colors.pasx2_blue, fontSize = 14.sp, fontWeight = FontWeight.Bold)
ControllerMappings.stickTargets.forEach { t ->
StickPickItem(t.label, current == t.code, "$layer.btn.${t.code}") { onPick(t.code) }
}
Spacer(Modifier.height(6.dp))
Text(str("pad.stickTarget.hotkeys"), color = Colors.pasx2_blue, fontSize = 14.sp, fontWeight = FontWeight.Bold)
ControllerMappings.SysHotkey.entries.forEach { h ->
val hc = ControllerMappings.stickCodeForHotkey(h)
StickPickItem("Hotkey: ${h.label}", current == hc, "$layer.hk.${h.name}") { onPick(hc) }
}
}
Spacer(Modifier.height(6.dp))
Text(str("pad.stickTarget.hotkeys"), color = Colors.pasx2_blue, fontSize = 14.sp, fontWeight = FontWeight.Bold)
ControllerMappings.SysHotkey.entries.forEach { h ->
val hc = ControllerMappings.stickCodeForHotkey(h)
StickPickItem("Hotkey: ${h.label}", current == hc) { onPick(hc) }
Spacer(Modifier.height(14.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
PickerButton(str("action.cancel"), "$layer.cancel", onDismiss)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(str("action.cancel"), color = Colors.pasx2_blue) }
},
)
}
}
}
/** Shared footer button for the two pickers below. */
@Composable
private fun PickerButton(label: String, id: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.controllerFocusable(
controllerId = id,
shape = RoundedCornerShape(14.dp),
onConfirm = onClick,
),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
label,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun StickPickItem(label: String, selected: Boolean, onClick: () -> Unit) {
private fun StickPickItem(label: String, selected: Boolean, id: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable { onClick() }
.controllerFocusable(controllerId = id, shape = RoundedCornerShape(10.dp), onConfirm = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -1268,49 +1308,79 @@ private fun MacroConfigDialog(
val selected = remember(macroId) {
mutableStateListOf<Int>().apply { addAll(TouchControls.macroCodes(macroId)) }
}
AlertDialog(
onDismissRequest = onDismiss,
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
textContentColor = MaterialTheme.colorScheme.onSurface,
title = { Text("${str("pad.action.edit")}: ${macroId.label}", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) },
text = {
Column(Modifier.verticalScroll(remember { ScrollState(0) })) {
val layer = "macro-config:${macroId.name}"
val save = {
TouchControls.setMacroCodes(macroId, selected.toList())
onSaved()
onDismiss()
}
com.armsx2.ui.common.PadModal(key = layer, onDismiss = onDismiss) {
Surface(
modifier = Modifier
.padding(24.dp)
.widthIn(max = 420.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
tonalElevation = 6.dp,
) {
Column(Modifier.padding(20.dp)) {
Text(
str("pad.macroConfig.intro"),
color = Color(0xFFBBBBBB), fontSize = 15.sp,
"${str("pad.action.edit")}: ${macroId.label}",
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
TouchControls.macroAssignableTargets.forEach { t ->
val on = t.code in selected
Row(
Modifier
.fillMaxWidth()
.height(52.dp)
.clickable { if (on) selected.remove(t.code) else selected.add(t.code) }
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (on) "" else "",
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(Modifier.width(12.dp))
Text(t.label, color = MaterialTheme.colorScheme.onSurface, fontSize = 14.sp)
// Plain Column, not Lazy — see the note on the stick picker.
Column(
Modifier
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
str("pad.macroConfig.intro"),
color = Color(0xFFBBBBBB), fontSize = 15.sp,
)
Spacer(Modifier.height(8.dp))
TouchControls.macroAssignableTargets.forEach { t ->
val on = t.code in selected
val toggle = { if (on) selected.remove(t.code) else selected.add(t.code); Unit }
Row(
Modifier
.fillMaxWidth()
.height(52.dp)
.clickable { toggle() }
// Left/Right clear and set, matching every other toggle in the app,
// so the row behaves the same inside this panel as outside it.
.controllerFocusable(
controllerId = "$layer.${t.code}",
shape = RoundedCornerShape(10.dp),
onConfirm = toggle,
onLeft = { if (on) selected.remove(t.code) },
onRight = { if (!on) selected.add(t.code) },
)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (on) "" else "",
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(Modifier.width(12.dp))
Text(t.label, color = MaterialTheme.colorScheme.onSurface, fontSize = 14.sp)
}
}
}
Spacer(Modifier.height(14.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
PickerButton(str("action.cancel"), "$layer.cancel", onDismiss)
Spacer(Modifier.width(10.dp))
PickerButton(str("action.save"), "$layer.save", save)
}
}
},
confirmButton = {
TextButton(onClick = {
TouchControls.setMacroCodes(macroId, selected.toList())
onSaved()
onDismiss()
}) { Text(str("action.save")) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(str("action.cancel")) } },
)
}
}
}
@Composable