package no.naiv.tiltshift.ui import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculateRotation import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import no.naiv.tiltshift.effect.BlurParameters import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin /** * Type of gesture being performed. */ private enum class GestureType { NONE, DRAG_POSITION, // Single finger drag to move focus position ROTATE, // Two-finger rotation PINCH_SIZE, // Pinch near blur edges to resize PINCH_ZOOM // Pinch in center to zoom camera } /** * Overlay that shows tilt-shift effect controls and handles gestures. */ @Composable fun TiltShiftOverlay( params: BlurParameters, onParamsChange: (BlurParameters) -> Unit, onZoomChange: (Float) -> Unit, modifier: Modifier = Modifier ) { var currentGesture by remember { mutableStateOf(GestureType.NONE) } var initialZoom by remember { mutableFloatStateOf(1f) } var initialAngle by remember { mutableFloatStateOf(0f) } var initialSize by remember { mutableFloatStateOf(0.3f) } Canvas( modifier = modifier .fillMaxSize() .pointerInput(Unit) { awaitEachGesture { val firstDown = awaitFirstDown(requireUnconsumed = false) currentGesture = GestureType.NONE var previousCentroid = firstDown.position var previousPointerCount = 1 var accumulatedRotation = 0f var accumulatedZoom = 1f initialAngle = params.angle initialSize = params.size initialZoom = 1f do { val event = awaitPointerEvent() val pointers = event.changes.filter { it.pressed } if (pointers.isEmpty()) break val centroid = if (pointers.size >= 2) { event.calculateCentroid() } else { pointers.first().position } when { // Two or more fingers pointers.size >= 2 -> { val rotation = event.calculateRotation() val zoom = event.calculateZoom() // Determine gesture type based on touch positions if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) { currentGesture = determineGestureType( centroid, size.width.toFloat(), size.height.toFloat(), params ) } when (currentGesture) { GestureType.ROTATE -> { accumulatedRotation += rotation val newAngle = initialAngle + accumulatedRotation onParamsChange(params.copy(angle = newAngle)) } GestureType.PINCH_SIZE -> { accumulatedZoom *= zoom val newSize = (initialSize * accumulatedZoom) .coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE) onParamsChange(params.copy(size = newSize)) } GestureType.PINCH_ZOOM -> { onZoomChange(zoom) } else -> {} } } // Single finger pointers.size == 1 -> { if (currentGesture == GestureType.NONE) { currentGesture = GestureType.DRAG_POSITION } if (currentGesture == GestureType.DRAG_POSITION) { val deltaY = (centroid.y - previousCentroid.y) / size.height val newPosition = (params.position + deltaY).coerceIn(0f, 1f) onParamsChange(params.copy(position = newPosition)) } } } previousCentroid = centroid previousPointerCount = pointers.size // Consume all pointer changes pointers.forEach { it.consume() } } while (event.type != PointerEventType.Release) currentGesture = GestureType.NONE } } ) { drawTiltShiftOverlay(params) } } /** * Determines the type of two-finger gesture based on touch position. */ private fun determineGestureType( centroid: Offset, width: Float, height: Float, params: BlurParameters ): GestureType { // Calculate distance from focus center line val focusCenterY = height * params.position val focusHalfHeight = height * params.size * 0.5f // Rotate centroid to align with focus line val dx = centroid.x - width / 2f val dy = centroid.y - focusCenterY val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle) val distFromCenter = kotlin.math.abs(rotatedY) return when { // Near the edges of the blur zone -> size adjustment distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> { GestureType.PINCH_SIZE } // Inside the focus zone -> rotation distFromCenter < focusHalfHeight * 0.7f -> { GestureType.ROTATE } // Outside -> camera zoom else -> { GestureType.PINCH_ZOOM } } } /** * Draws the tilt-shift visualization overlay. */ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { val width = size.width val height = size.height val centerY = height * params.position val focusHalfHeight = height * params.size * 0.5f val angleDegrees = params.angle * (180f / PI.toFloat()) // Colors for overlay val focusLineColor = Color(0xFFFFB300) // Amber val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f) rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) { // Draw blur zone indicators (top and bottom) drawRect( color = blurZoneColor, topLeft = Offset(0f, 0f), size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight) ) drawRect( color = blurZoneColor, topLeft = Offset(0f, centerY + focusHalfHeight), size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight)) ) // Draw focus zone boundary lines drawLine( color = focusLineColor, start = Offset(0f, centerY - focusHalfHeight), end = Offset(width, centerY - focusHalfHeight), strokeWidth = 2.dp.toPx(), pathEffect = dashEffect ) drawLine( color = focusLineColor, start = Offset(0f, centerY + focusHalfHeight), end = Offset(width, centerY + focusHalfHeight), strokeWidth = 2.dp.toPx(), pathEffect = dashEffect ) // Draw center focus line drawLine( color = focusLineColor, start = Offset(0f, centerY), end = Offset(width, centerY), strokeWidth = 3.dp.toPx() ) // Draw rotation indicator at center val indicatorRadius = 30.dp.toPx() drawCircle( color = focusLineColor.copy(alpha = 0.5f), radius = indicatorRadius, center = Offset(width / 2f, centerY), style = Stroke(width = 2.dp.toPx()) ) // Draw angle tick mark val tickLength = 15.dp.toPx() drawLine( color = focusLineColor, start = Offset(width / 2f, centerY - indicatorRadius + tickLength), end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()), strokeWidth = 3.dp.toPx() ) } }