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 } // Sensitivity factors for gesture controls (lower = less sensitive) private const val POSITION_SENSITIVITY = 0.3f // Drag to move focus line private const val ROTATION_SENSITIVITY = 0.4f // Two-finger rotation private const val SIZE_SENSITIVITY = 0.5f // Pinch to resize blur zone private const val ZOOM_SENSITIVITY = 0.6f // Pinch 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) } var initialPosition by remember { mutableFloatStateOf(0.5f) } 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 var accumulatedDragY = 0f initialAngle = params.angle initialSize = params.size initialPosition = params.position 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 -> { // Apply dampening to rotation accumulatedRotation += rotation * ROTATION_SENSITIVITY val newAngle = initialAngle + accumulatedRotation onParamsChange(params.copy(angle = newAngle)) } GestureType.PINCH_SIZE -> { // Apply dampening to size change val dampenedZoom = 1f + (zoom - 1f) * SIZE_SENSITIVITY accumulatedZoom *= dampenedZoom val newSize = (initialSize * accumulatedZoom) .coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE) onParamsChange(params.copy(size = newSize)) } GestureType.PINCH_ZOOM -> { // Apply dampening to camera zoom val dampenedZoom = 1f + (zoom - 1f) * ZOOM_SENSITIVITY onZoomChange(dampenedZoom) } else -> {} } } // Single finger pointers.size == 1 -> { if (currentGesture == GestureType.NONE) { currentGesture = GestureType.DRAG_POSITION } if (currentGesture == GestureType.DRAG_POSITION) { // Apply dampening to position drag val deltaY = (centroid.y - previousCentroid.y) / size.height accumulatedDragY += deltaY * POSITION_SENSITIVITY val newPosition = (initialPosition + accumulatedDragY).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() ) } }