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.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.geometry.Size 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.BlurMode import no.naiv.tiltshift.effect.BlurParameters import kotlin.math.PI import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt /** * 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 size): Rotation * - Near focus region (30% - 200% of focus size): Size adjustment * - Far outside (> 200%): Camera zoom */ private fun determineGestureType( centroid: Offset, width: Float, height: Float, params: BlurParameters ): GestureType { val focusCenterX = width * params.positionX val focusCenterY = height * params.positionY val focusSize = height * params.size * 0.5f val dx = centroid.x - focusCenterX val dy = centroid.y - focusCenterY val distFromCenter = when (params.mode) { BlurMode.LINEAR -> { // For linear mode, use perpendicular distance to focus line val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle) kotlin.math.abs(rotatedY) } BlurMode.RADIAL -> { // For radial mode, use distance from center sqrt(dx * dx + dy * dy) } } return when { // Very center of focus zone -> rotation (small area) distFromCenter < focusSize * 0.3f -> GestureType.ROTATE // Near the blur effect -> size adjustment (large area) distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE // Far outside -> camera zoom else -> GestureType.PINCH_ZOOM } } /** * Draws the tilt-shift visualization overlay. * Supports both linear and radial modes. */ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { when (params.mode) { BlurMode.LINEAR -> drawLinearOverlay(params) BlurMode.RADIAL -> drawRadialOverlay(params) } } /** * Draws the linear mode overlay (horizontal band with rotation). */ private fun DrawScope.drawLinearOverlay(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 = sqrt(width * width + height * height) val extendedHalf = diagonal rotate(angleDegrees, pivot = Offset(centerX, centerY)) { // Draw blur zone indicators (top and bottom) drawRect( color = blurZoneColor, topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf), size = Size(extendedHalf * 2, extendedHalf) ) drawRect( color = blurZoneColor, topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight), size = Size(extendedHalf * 2, extendedHalf) ) // Draw focus zone boundary lines 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 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() ) } } /** * Draws the radial mode overlay (ellipse/circle). */ private fun DrawScope.drawRadialOverlay(params: BlurParameters) { val width = size.width val height = size.height val centerX = width * params.positionX val centerY = height * params.positionY val focusRadius = height * params.size * 0.5f val angleDegrees = params.angle * (180f / PI.toFloat()) // Colors for overlay val focusLineColor = Color(0xFFFFB300) // Amber val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f) // Calculate ellipse dimensions based on aspect ratio val ellipseWidth = focusRadius * 2 * params.aspectRatio val ellipseHeight = focusRadius * 2 rotate(angleDegrees, pivot = Offset(centerX, centerY)) { // Draw focus ellipse outline (inner boundary) drawOval( color = focusLineColor, topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2), size = Size(ellipseWidth, ellipseHeight), style = Stroke(width = 3.dp.toPx()) ) // Draw outer blur boundary (with falloff) val outerScale = 1f + params.falloff drawOval( color = focusLineColor.copy(alpha = 0.5f), topLeft = Offset( centerX - (ellipseWidth * outerScale) / 2, centerY - (ellipseHeight * outerScale) / 2 ), size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale), style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect) ) // Draw center crosshair val crosshairSize = 20.dp.toPx() drawLine( color = focusLineColor, start = Offset(centerX - crosshairSize, centerY), end = Offset(centerX + crosshairSize, centerY), strokeWidth = 2.dp.toPx() ) drawLine( color = focusLineColor, start = Offset(centerX, centerY - crosshairSize), end = Offset(centerX, centerY + crosshairSize), strokeWidth = 2.dp.toPx() ) // Draw rotation indicator (small line at top of ellipse) drawLine( color = focusLineColor, start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()), end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()), strokeWidth = 3.dp.toPx() ) } }