package dev.stave.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
/**
* D-pad long-press for TV remotes.
*
* `Modifier.combinedClickable(onLongClick = ...)` detects long-press via pointer input (touch).
* On an Android TV remote the select button arrives as discrete KeyDown/KeyUp events, and the
* gesture recognizer never promotes a held select into a long-press — the user gets a regular
* click on release instead (exactly what they reported: "inadvertently selected the thing I
* was trying to long-press on").
*
* This modifier listens in the preview phase:
*
* 1. Android sends a synthetic KeyDown with `FLAG_LONG_PRESS` set once the key has been held
* past the system long-press threshold (~500ms, configurable). We mark the press as
* "ready to long-press on release" and consume the event so it doesn't cascade.
* 2. The user releases. We see KeyUp; if the flag is set, NOW we fire onLongPress and consume
* the KeyUp so the enclosing `clickable`/`combinedClickable` doesn't also fire `onClick`.
*
* Why fire on KeyUp rather than on the LONG_PRESS KeyDown: if onLongPress synchronously moves
* focus (the common case — it opens an ActionSheet that auto-focuses its first item), the
* subsequent KeyUp gets dispatched to whatever element is focused at that point. With the old
* code that meant the freshly-opened sheet's first action received a synthetic select-press
* and immediately fired its onClick — exactly the bug the user reported in the queue
* (long-pressing an item would trip the first action of the sheet that opened). By deferring
* the focus-changing call to KeyUp, the entire press cycle completes on the original element
* before any focus moves.
*
* Apply BEFORE `clickable` in the modifier chain so the KeyUp we consume never reaches it.
* Touch long-press (e.g. Android TV with a mouse/touch emulator) still works via combinedClickable.
*/
@Composable
fun Modifier.onDpadLongPress(onLongPress: (() -> Unit)?): Modifier {
if (onLongPress == null) return this
// pendingLongPress tracks "we observed the LONG_PRESS KeyDown, fire onLongPress on the
// next KeyUp". Reset to false after firing so a second held-press starts fresh.
val pendingLongPress = remember { mutableStateOf(false) }
return this.onPreviewKeyEvent { event ->
val isSelect = event.key == Key.DirectionCenter ||
event.key == Key.Enter ||
event.key == Key.NumPadEnter
if (!isSelect) return@onPreviewKeyEvent false
when {
// Long-press threshold reached. Don't fire yet — wait for KeyUp so any focus-
// moving action (e.g. opening a sheet) happens AFTER the KeyUp is consumed and
// can't be misdirected to the newly-focused element.
event.type == KeyEventType.KeyDown && event.nativeKeyEvent.isLongPress -> {
pendingLongPress.value = true
true
}
// Release after a long-press KeyDown was observed: now fire onLongPress and
// consume the KeyUp so the enclosing clickable doesn't ALSO fire its onClick.
event.type == KeyEventType.KeyUp && pendingLongPress.value -> {
pendingLongPress.value = false
onLongPress()
true
}
else -> false
}
}
}