package com.suno.android.common_ui.components.timebox import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculateCentroidSize import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateRotation import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChangeConsumed import androidx.compose.ui.input.pointer.positionChanged import com.suno.android.common_ui.components.waveform.DpPerSecond import com.suno.android.common_ui.components.waveform.div import kotlin.math.abs import kotlin.time.Duration /** * A modifier that adds pan-only gesture support to a TimeBox. * * This is a convenience wrapper around [timeBoxGestures] with zooming disabled. * It provides basic panning functionality for scrolling through time. * * @param startOffset The initial time offset of the viewport * @param resolution The current resolution (pixels per second) * @param onOffsetChanged Callback when the time offset changes due to panning * @param onGestureStart Callback when a gesture starts * @param onGestureEnd Callback when a gesture ends * @return A modifier that adds pan-only gesture support */ @Composable fun Modifier.panOnlyTimeBoxGestures( startOffset: Duration, resolution: DpPerSecond, onOffsetChanged: (Duration) -> Unit, onGestureStart: () -> Unit = {}, onGestureEnd: () -> Unit = {}, ) = timeBoxGestures( zoomEnabled = false, onResolutionChanged = { }, startOffset = startOffset, resolution = resolution, onOffsetChanged = onOffsetChanged, onGestureStart = onGestureStart, onGestureEnd = onGestureEnd, ) /** * A modifier that adds gesture support to a TimeBox for panning and zooming. * * This modifier enables users to: * - Pan to scroll through time * - Zoom to change the time resolution (when enabled) * * The gestures are implemented to maintain the point under the user's finger * as the center of zooming, providing a natural interaction experience. * * @param startOffset The initial time offset of the viewport * @param resolution The current resolution (pixels per second) * @param zoomEnabled Whether zooming is enabled, defaults to true * @param onOffsetChanged Callback when the time offset changes due to panning * @param onResolutionChanged Callback when the resolution changes due to zooming * @param onGestureStart Callback when a gesture starts * @param onGestureEnd Callback when a gesture ends * @return A modifier that adds gesture support */ @Composable fun Modifier.timeBoxGestures( startOffset: Duration, resolution: DpPerSecond, zoomEnabled: Boolean = true, onOffsetChanged: (Duration) -> Unit, onResolutionChanged: (DpPerSecond) -> Unit, onGestureStart: () -> Unit = {}, onGestureEnd: () -> Unit = {}, ): Modifier { // note, these are called "updated" because compose is keeping their state updated in the lambda below // in the context of the gesture lambda, their value is actually the offset/resolution at the previous frame val updatedStartOffset by rememberUpdatedState(startOffset) val updatedResolution by rememberUpdatedState(resolution) return this.pointerInput(Unit) { detectTransformGesturesWithCallbacks( onGestureStart = onGestureStart, onGestureEnd = onGestureEnd, ) { centroid, pan, zoom, rotation -> if (zoomEnabled) { val oldResolution = updatedResolution val oldStartOffset = updatedStartOffset val newResolution = updatedResolution * zoom // adapted from example here: https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/package-summary#(androidx.compose.ui.input.pointer.PointerInputScope).detectTransformGestures(kotlin.Boolean,kotlin.Function4) // For natural zooming and rotating, the centroid of the gesture should // be the fixed point where zooming and rotating occurs. // We compute where the centroid was (in the pre-transformed coordinate // space) val oldCentroidPosition = oldStartOffset + centroid.x.toDp() / oldResolution // then compute where it will be after this delta. val newCentroidPosition = centroid.x.toDp() / newResolution // We then compute what the new offset should be to keep the centroid // visually stationary for rotating and zooming, val centroidOffset = oldCentroidPosition - newCentroidPosition // and also apply the pan. val timePanned = pan.x.toDp() / oldResolution onOffsetChanged(centroidOffset - timePanned) // apply zoom changes onResolutionChanged(newResolution) } else { onOffsetChanged(updatedStartOffset - pan.x.toDp() / updatedResolution) } } } } /** * A modifier that adds gesture support to a TimeBox using a [TimeBoxState]. * * This is a convenience wrapper around [timeBoxGestures] that uses a [TimeBoxState] * to manage the viewport state. It automatically updates the state's properties * when gestures occur. * * @param state The [TimeBoxState] to update with gesture changes * @param zoomEnabled Whether zooming is enabled, defaults to true * @param onGestureStart Callback when a gesture starts * @param onGestureEnd Callback when a gesture ends * @param onOffsetUpdate Optional transformation of the offset before updating state * @return A modifier that adds gesture support using the provided state */ @Composable fun Modifier.timeBoxGestures( state: TimeBoxState, zoomEnabled: Boolean = true, onGestureStart: () -> Unit = {}, onGestureEnd: () -> Unit = {}, onResolutionUpdate: (DpPerSecond) -> DpPerSecond = { it }, onOffsetUpdate: (Duration) -> Duration = { it }, ) = timeBoxGestures( startOffset = state.startTimeOffset, resolution = state.resolution, zoomEnabled = zoomEnabled, onGestureStart = onGestureStart, onGestureEnd = onGestureEnd, onOffsetChanged = { state.startTimeOffsetState.value = onOffsetUpdate(it) }, onResolutionChanged = { state.resolutionState.value = onResolutionUpdate(it) }, ) /** * Detects transform gestures (pan, zoom, rotation) with support for callbacks. * * This is a low-level gesture detection function that provides more control over * gesture handling than the standard Compose gesture detection. It supports: * - Pan detection with touch slop * - Zoom detection with touch slop * - Rotation detection with touch slop * - Optional pan-zoom locking * - Gesture start/end callbacks * * @param panZoomLock If true, locks to pan-zoom mode when rotation is small * @param onGestureStart Callback when a gesture starts * @param onGestureEnd Callback when a gesture ends * @param onGesture Callback with the current gesture state (centroid, pan, zoom, rotation) */ suspend fun PointerInputScope.detectTransformGesturesWithCallbacks( panZoomLock: Boolean = false, onGestureStart: () -> Unit = {}, onGestureEnd: () -> Unit = {}, onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit, ) { forEachGesture { awaitPointerEventScope { var rotation = 0f var zoom = 1f var pan = Offset.Zero var pastTouchSlop = false val touchSlop = viewConfiguration.touchSlop var lockedToPanZoom = false awaitFirstDown(requireUnconsumed = false) onGestureStart() do { val event = awaitPointerEvent() val canceled = event.changes.any { it.positionChangeConsumed() } if (!canceled) { val zoomChange = event.calculateZoom() val rotationChange = event.calculateRotation() val panChange = event.calculatePan() if (!pastTouchSlop) { zoom *= zoomChange rotation += rotationChange pan += panChange val centroidSize = event.calculateCentroidSize(useCurrent = false) val zoomMotion = abs(1 - zoom) * centroidSize val rotationMotion = abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f) val panMotion = pan.getDistance() if (zoomMotion > touchSlop || rotationMotion > touchSlop || panMotion > touchSlop) { pastTouchSlop = true lockedToPanZoom = panZoomLock && rotationMotion < touchSlop } } if (pastTouchSlop) { val centroid = event.calculateCentroid(useCurrent = false) val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange if (effectiveRotation != 0f || zoomChange != 1f || panChange != Offset.Zero) { onGesture(centroid, panChange, zoomChange, effectiveRotation) } event.changes.forEach { if (it.positionChanged()) { it.consume() } } } } } while (!canceled && event.changes.any { it.pressed }) onGestureEnd() } } }