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.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.rememberUpdatedState 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.atan2 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 center ROTATE, // Two-finger rotation PINCH_SIZE, // Pinch near blur edges to resize PINCH_ZOOM // Pinch in center to zoom camera } // Sensitivity factor for size pinch (lower = less sensitive) private const val SIZE_SENSITIVITY = 0.3f private const val ZOOM_SENSITIVITY = 0.5f /** * Calculates the angle between two touch points. */ private fun angleBetweenPoints(p1: Offset, p2: Offset): Float { return atan2(p2.y - p1.y, p2.x - p1.x) } /** * Overlay that shows tilt-shift effect controls and handles gestures. */ @Composable fun TiltShiftOverlay( params: BlurParameters, onParamsChange: (BlurParameters) -> Unit, onZoomChange: (Float) -> Unit, modifier: Modifier = Modifier ) { // Use rememberUpdatedState to always get latest values inside pointerInput val currentParams by rememberUpdatedState(params) val currentOnParamsChange by rememberUpdatedState(onParamsChange) val currentOnZoomChange by rememberUpdatedState(onZoomChange) var currentGesture by remember { mutableStateOf(GestureType.NONE) } Canvas( modifier = modifier .fillMaxSize() .pointerInput(Unit) { awaitEachGesture { val firstDown = awaitFirstDown(requireUnconsumed = false) currentGesture = GestureType.NONE // Capture initial state at gesture start val gestureStartParams = currentParams var initialTouchAngle = 0f var initialDragCentroid = firstDown.position var accumulatedZoom = 1f var rotationInitialized = false var dragInitialized = false var previousCentroid = firstDown.position 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 p1 = pointers[0].position val p2 = pointers[1].position val currentTouchAngle = angleBetweenPoints(p1, p2) 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(), currentParams ) rotationInitialized = false } when (currentGesture) { GestureType.ROTATE -> { if (!rotationInitialized) { initialTouchAngle = currentTouchAngle rotationInitialized = true } else { val angleDelta = currentTouchAngle - initialTouchAngle val newAngle = gestureStartParams.angle + angleDelta currentOnParamsChange(currentParams.copy(angle = newAngle)) } } GestureType.PINCH_SIZE -> { val dampenedZoom = 1f + (zoom - 1f) * SIZE_SENSITIVITY accumulatedZoom *= dampenedZoom val newSize = (gestureStartParams.size * accumulatedZoom) .coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE) currentOnParamsChange(currentParams.copy(size = newSize)) } GestureType.PINCH_ZOOM -> { val dampenedZoom = 1f + (zoom - 1f) * ZOOM_SENSITIVITY currentOnZoomChange(dampenedZoom) } else -> {} } } // Single finger - drag to move focus center 1:1 pointers.size == 1 -> { if (currentGesture == GestureType.NONE) { currentGesture = GestureType.DRAG_POSITION dragInitialized = false } if (currentGesture == GestureType.DRAG_POSITION) { if (!dragInitialized) { initialDragCentroid = centroid dragInitialized = true } else { // Calculate total drag from initial touch point val totalDragX = (centroid.x - initialDragCentroid.x) / size.width val totalDragY = (centroid.y - initialDragCentroid.y) / size.height val newX = (gestureStartParams.positionX + totalDragX).coerceIn(0f, 1f) val newY = (gestureStartParams.positionY + totalDragY).coerceIn(0f, 1f) currentOnParamsChange(currentParams.copy(positionX = newX, positionY = newY)) } } } } previousCentroid = centroid 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. * * Zones (from center outward): * - Very center (< 30% of focus height): Rotation * - Near focus line (30% - 200% of focus height): Size adjustment * - Far outside (> 200%): Camera zoom */ private fun determineGestureType( centroid: Offset, width: Float, height: Float, params: BlurParameters ): GestureType { // Calculate distance from focus center val focusCenterX = width * params.positionX val focusCenterY = height * params.positionY val focusHalfHeight = height * params.size * 0.5f // Rotate centroid to align with focus line val dx = centroid.x - focusCenterX val dy = centroid.y - focusCenterY val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle) val distFromCenter = kotlin.math.abs(rotatedY) return when { // Very center of focus zone -> rotation (small area) distFromCenter < focusHalfHeight * 0.3f -> { GestureType.ROTATE } // Anywhere near the blur effect -> size adjustment (large area) distFromCenter < focusHalfHeight * 2.0f -> { GestureType.PINCH_SIZE } // Far outside -> camera zoom else -> { GestureType.PINCH_ZOOM } } } /** * Draws the tilt-shift visualization overlay. * Uses extended geometry so rotated elements don't clip at screen edges. */ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { val width = size.width val height = size.height val centerX = width * params.positionX val centerY = height * params.positionY 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) // Calculate diagonal for extended drawing (ensures coverage when rotated) val diagonal = kotlin.math.sqrt(width * width + height * height) val extendedHalf = diagonal // Extend lines/rects well beyond screen rotate(angleDegrees, pivot = Offset(centerX, centerY)) { // Draw blur zone indicators (top and bottom) - extended horizontally drawRect( color = blurZoneColor, topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf), size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf) ) drawRect( color = blurZoneColor, topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight), size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf) ) // Draw focus zone boundary lines - extended horizontally drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), end = Offset(centerX + extendedHalf, centerY - focusHalfHeight), strokeWidth = 2.dp.toPx(), pathEffect = dashEffect ) drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY + focusHalfHeight), end = Offset(centerX + extendedHalf, centerY + focusHalfHeight), strokeWidth = 2.dp.toPx(), pathEffect = dashEffect ) // Draw center focus line - extended horizontally drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY), end = Offset(centerX + extendedHalf, 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(centerX, centerY), style = Stroke(width = 2.dp.toPx()) ) // Draw angle tick mark val tickLength = 15.dp.toPx() drawLine( color = focusLineColor, start = Offset(centerX, centerY - indicatorRadius + tickLength), end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()), strokeWidth = 3.dp.toPx() ) } }