From 88d04515e2b47d6cfdb9a6ff21a21242bc85075d Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 17:50:30 +0100 Subject: [PATCH] Extract StackBlur and split CameraScreen into smaller files Move the ~220-line stack blur algorithm from ImageCaptureHandler into its own util/StackBlur.kt object, making it independently testable and reducing ImageCaptureHandler from 759 to 531 lines. Split CameraScreen.kt (873 lines) by extracting: - ControlPanel.kt: ModeToggle, ControlPanel, SliderControl - CaptureControls.kt: CaptureButton, LastPhotoThumbnail CameraScreen.kt is now 609 lines focused on layout and state wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tiltshift/camera/ImageCaptureHandler.kt | 229 +-------------- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 278 +----------------- .../no/naiv/tiltshift/ui/CaptureControls.kt | 107 +++++++ .../java/no/naiv/tiltshift/ui/ControlPanel.kt | 218 ++++++++++++++ .../java/no/naiv/tiltshift/util/StackBlur.kt | 248 ++++++++++++++++ 5 files changed, 579 insertions(+), 501 deletions(-) create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index 4736e52..b0401e7 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -18,6 +18,7 @@ import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.SaveResult +import no.naiv.tiltshift.util.StackBlur import java.util.concurrent.Executor import kotlin.coroutines.resume import kotlin.math.cos @@ -386,7 +387,7 @@ class ImageCaptureHandler( scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) - blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) + blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) scaled.recycle() scaled = null @@ -530,230 +531,4 @@ class ImageCaptureHandler( return fullPixels } - /** - * Fast stack blur algorithm. - */ - private fun stackBlur(bitmap: Bitmap, radius: Int): Bitmap { - if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true) - - val w = bitmap.width - val h = bitmap.height - val pix = IntArray(w * h) - bitmap.getPixels(pix, 0, w, 0, 0, w, h) - - val wm = w - 1 - val hm = h - 1 - val wh = w * h - val div = radius + radius + 1 - - val r = IntArray(wh) - val g = IntArray(wh) - val b = IntArray(wh) - var rsum: Int - var gsum: Int - var bsum: Int - var x: Int - var y: Int - var i: Int - var p: Int - var yp: Int - var yi: Int - var yw: Int - val vmin = IntArray(maxOf(w, h)) - - var divsum = (div + 1) shr 1 - divsum *= divsum - val dv = IntArray(256 * divsum) - for (i2 in 0 until 256 * divsum) { - dv[i2] = (i2 / divsum) - } - - yi = 0 - yw = 0 - - val stack = Array(div) { IntArray(3) } - var stackpointer: Int - var stackstart: Int - var sir: IntArray - var rbs: Int - val r1 = radius + 1 - var routsum: Int - var goutsum: Int - var boutsum: Int - var rinsum: Int - var ginsum: Int - var binsum: Int - - for (y2 in 0 until h) { - rinsum = 0 - ginsum = 0 - binsum = 0 - routsum = 0 - goutsum = 0 - boutsum = 0 - rsum = 0 - gsum = 0 - bsum = 0 - for (i2 in -radius..radius) { - p = pix[yi + minOf(wm, maxOf(i2, 0))] - sir = stack[i2 + radius] - sir[0] = (p and 0xff0000) shr 16 - sir[1] = (p and 0x00ff00) shr 8 - sir[2] = (p and 0x0000ff) - rbs = r1 - kotlin.math.abs(i2) - rsum += sir[0] * rbs - gsum += sir[1] * rbs - bsum += sir[2] * rbs - if (i2 > 0) { - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - } else { - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - } - } - stackpointer = radius - - for (x2 in 0 until w) { - r[yi] = dv[rsum] - g[yi] = dv[gsum] - b[yi] = dv[bsum] - - rsum -= routsum - gsum -= goutsum - bsum -= boutsum - - stackstart = stackpointer - radius + div - sir = stack[stackstart % div] - - routsum -= sir[0] - goutsum -= sir[1] - boutsum -= sir[2] - - if (y2 == 0) { - vmin[x2] = minOf(x2 + radius + 1, wm) - } - p = pix[yw + vmin[x2]] - - sir[0] = (p and 0xff0000) shr 16 - sir[1] = (p and 0x00ff00) shr 8 - sir[2] = (p and 0x0000ff) - - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - - rsum += rinsum - gsum += ginsum - bsum += binsum - - stackpointer = (stackpointer + 1) % div - sir = stack[(stackpointer) % div] - - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - - rinsum -= sir[0] - ginsum -= sir[1] - binsum -= sir[2] - - yi++ - } - yw += w - } - for (x2 in 0 until w) { - rinsum = 0 - ginsum = 0 - binsum = 0 - routsum = 0 - goutsum = 0 - boutsum = 0 - rsum = 0 - gsum = 0 - bsum = 0 - yp = -radius * w - for (i2 in -radius..radius) { - yi = maxOf(0, yp) + x2 - - sir = stack[i2 + radius] - - sir[0] = r[yi] - sir[1] = g[yi] - sir[2] = b[yi] - - rbs = r1 - kotlin.math.abs(i2) - - rsum += r[yi] * rbs - gsum += g[yi] * rbs - bsum += b[yi] * rbs - - if (i2 > 0) { - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - } else { - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - } - - if (i2 < hm) { - yp += w - } - } - yi = x2 - stackpointer = radius - for (y2 in 0 until h) { - pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] - - rsum -= routsum - gsum -= goutsum - bsum -= boutsum - - stackstart = stackpointer - radius + div - sir = stack[stackstart % div] - - routsum -= sir[0] - goutsum -= sir[1] - boutsum -= sir[2] - - if (x2 == 0) { - vmin[y2] = minOf(y2 + r1, hm) * w - } - p = x2 + vmin[y2] - - sir[0] = r[p] - sir[1] = g[p] - sir[2] = b[p] - - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - - rsum += rinsum - gsum += ginsum - bsum += binsum - - stackpointer = (stackpointer + 1) % div - sir = stack[stackpointer] - - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - - rinsum -= sir[0] - ginsum -= sir[1] - binsum -= sir[2] - - yi += w - } - } - - val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - result.setPixels(pix, 0, w, 0, 0, w, h) - return result - } } diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 8012961..bbb706d 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,19 +1,16 @@ package no.naiv.tiltshift.ui import android.content.Intent -import android.graphics.Bitmap import android.graphics.SurfaceTexture import android.opengl.GLSurfaceView import android.util.Log -import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,24 +26,21 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FlipCameraAndroid -import androidx.compose.material.icons.filled.PhotoLibrary -import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.LocationOff import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -65,10 +59,9 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp @@ -79,7 +72,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest -import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.ui.theme.AppColors @@ -608,265 +600,3 @@ fun CameraScreen( } } -/** - * Mode toggle for Linear / Radial blur modes. - */ -@Composable -private fun ModeToggle( - currentMode: BlurMode, - onModeChange: (BlurMode) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier - .clip(RoundedCornerShape(20.dp)) - .background(AppColors.OverlayDark) - .padding(4.dp), - horizontalArrangement = Arrangement.Center - ) { - ModeButton( - text = "Linear", - isSelected = currentMode == BlurMode.LINEAR, - onClick = { onModeChange(BlurMode.LINEAR) } - ) - Spacer(modifier = Modifier.width(4.dp)) - ModeButton( - text = "Radial", - isSelected = currentMode == BlurMode.RADIAL, - onClick = { onModeChange(BlurMode.RADIAL) } - ) - } -} - -@Composable -private fun ModeButton( - text: String, - isSelected: Boolean, - onClick: () -> Unit -) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(if (isSelected) AppColors.Accent else Color.Transparent) - .clickable(role = Role.Button, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp) - .semantics { - stateDescription = if (isSelected) "Selected" else "Not selected" - contentDescription = "$text blur mode" - }, - contentAlignment = Alignment.Center - ) { - Text( - text = text, - color = if (isSelected) Color.Black else Color.White, - fontSize = 14.sp - ) - } -} - -/** - * Control panel with sliders for blur parameters. - * Includes position/size/angle sliders as gesture alternatives for accessibility. - */ -@Composable -private fun ControlPanel( - params: BlurParameters, - onParamsChange: (BlurParameters) -> Unit, - onReset: () -> Unit, - modifier: Modifier = Modifier -) { - val currentParams by rememberUpdatedState(params) - val currentOnParamsChange by rememberUpdatedState(onParamsChange) - - Column( - modifier = modifier - .width(200.dp) - .clip(RoundedCornerShape(16.dp)) - .background(AppColors.OverlayDarker) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Reset button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - IconButton( - onClick = onReset, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = "Reset all parameters to defaults", - tint = Color.White, - modifier = Modifier.size(18.dp) - ) - } - } - - // Blur intensity slider - SliderControl( - label = "Blur", - value = params.blurAmount, - valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, - formatValue = { "${(it * 100).toInt()}%" }, - onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } - ) - - // Falloff slider - SliderControl( - label = "Falloff", - value = params.falloff, - valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, - formatValue = { "${(it * 100).toInt()}%" }, - onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) } - ) - - // Size slider (gesture alternative for pinch-to-resize) - SliderControl( - label = "Size", - value = params.size, - valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE, - formatValue = { "${(it * 100).toInt()}%" }, - onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) } - ) - - // Aspect ratio slider (radial mode only) - if (params.mode == BlurMode.RADIAL) { - SliderControl( - label = "Shape", - value = params.aspectRatio, - valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT, - formatValue = { "%.1f:1".format(it) }, - onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } - ) - } - - // Angle slider (gesture alternative for two-finger rotate) - SliderControl( - label = "Angle", - value = params.angle, - valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(), - formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" }, - onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) } - ) - } -} - -@Composable -private fun SliderControl( - label: String, - value: Float, - valueRange: ClosedFloatingPointRange, - formatValue: (Float) -> String = { "${(it * 100).toInt()}%" }, - onValueChange: (Float) -> Unit -) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = label, - color = Color.White, - fontSize = 12.sp - ) - Text( - text = formatValue(value), - color = Color.White.copy(alpha = 0.7f), - fontSize = 12.sp - ) - } - Slider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - colors = SliderDefaults.colors( - thumbColor = AppColors.Accent, - activeTrackColor = AppColors.Accent, - inactiveTrackColor = Color.White.copy(alpha = 0.3f) - ), - modifier = Modifier - .height(24.dp) - .semantics { contentDescription = "$label: ${formatValue(value)}" } - ) - } -} - -/** - * Capture button with processing indicator. - */ -@Composable -private fun CaptureButton( - isCapturing: Boolean, - isProcessing: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val outerSize = 72.dp - val innerSize = if (isCapturing) 48.dp else 60.dp - - Box( - modifier = modifier - .size(outerSize) - .clip(CircleShape) - .border(4.dp, Color.White, CircleShape) - .clickable( - enabled = !isCapturing, - role = Role.Button, - onClick = onClick - ) - .semantics { - contentDescription = "Capture photo with tilt-shift effect" - if (isCapturing) stateDescription = "Processing photo" - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(innerSize) - .clip(CircleShape) - .background(if (isCapturing) AppColors.Accent else Color.White), - contentAlignment = Alignment.Center - ) { - if (isProcessing) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.Black, - strokeWidth = 3.dp - ) - } - } - } -} - -/** - * Rounded thumbnail of the last captured photo. - * Tapping opens the image in the default photo viewer. - */ -@Composable -private fun LastPhotoThumbnail( - thumbnail: Bitmap?, - onTap: () -> Unit, - modifier: Modifier = Modifier -) { - AnimatedVisibility( - visible = thumbnail != null, - enter = fadeIn() + scaleIn(initialScale = 0.6f), - exit = fadeOut(), - modifier = modifier - ) { - thumbnail?.let { bmp -> - Image( - bitmap = bmp.asImageBitmap(), - contentDescription = "Last captured photo. Tap to open in viewer.", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(10.dp)) - .border(2.dp, Color.White, RoundedCornerShape(10.dp)) - .clickable(role = Role.Button, onClick = onTap) - ) - } - } -} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt b/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt new file mode 100644 index 0000000..efec080 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt @@ -0,0 +1,107 @@ +package no.naiv.tiltshift.ui + +import android.graphics.Bitmap +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import no.naiv.tiltshift.ui.theme.AppColors + +/** + * Capture button with processing indicator. + */ +@Composable +fun CaptureButton( + isCapturing: Boolean, + isProcessing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val outerSize = 72.dp + val innerSize = if (isCapturing) 48.dp else 60.dp + + Box( + modifier = modifier + .size(outerSize) + .clip(CircleShape) + .border(4.dp, Color.White, CircleShape) + .clickable( + enabled = !isCapturing, + role = Role.Button, + onClick = onClick + ) + .semantics { + contentDescription = "Capture photo with tilt-shift effect" + if (isCapturing) stateDescription = "Processing photo" + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(innerSize) + .clip(CircleShape) + .background(if (isCapturing) AppColors.Accent else Color.White), + contentAlignment = Alignment.Center + ) { + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.Black, + strokeWidth = 3.dp + ) + } + } + } +} + +/** + * Rounded thumbnail of the last captured photo. + * Tapping opens the image in the default photo viewer. + */ +@Composable +fun LastPhotoThumbnail( + thumbnail: Bitmap?, + onTap: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = thumbnail != null, + enter = fadeIn() + scaleIn(initialScale = 0.6f), + exit = fadeOut(), + modifier = modifier + ) { + thumbnail?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Last captured photo. Tap to open in viewer.", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(10.dp)) + .border(2.dp, Color.White, RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClick = onTap) + ) + } + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt new file mode 100644 index 0000000..ad94ad0 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt @@ -0,0 +1,218 @@ +package no.naiv.tiltshift.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import no.naiv.tiltshift.effect.BlurMode +import no.naiv.tiltshift.effect.BlurParameters +import no.naiv.tiltshift.ui.theme.AppColors + +/** + * Mode toggle for Linear / Radial blur modes. + */ +@Composable +fun ModeToggle( + currentMode: BlurMode, + onModeChange: (BlurMode) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .background(AppColors.OverlayDark) + .padding(4.dp), + horizontalArrangement = Arrangement.Center + ) { + ModeButton( + text = "Linear", + isSelected = currentMode == BlurMode.LINEAR, + onClick = { onModeChange(BlurMode.LINEAR) } + ) + Spacer(modifier = Modifier.width(4.dp)) + ModeButton( + text = "Radial", + isSelected = currentMode == BlurMode.RADIAL, + onClick = { onModeChange(BlurMode.RADIAL) } + ) + } +} + +@Composable +private fun ModeButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(if (isSelected) AppColors.Accent else Color.Transparent) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { + stateDescription = if (isSelected) "Selected" else "Not selected" + contentDescription = "$text blur mode" + }, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = if (isSelected) Color.Black else Color.White, + fontSize = 14.sp + ) + } +} + +/** + * Control panel with sliders for blur parameters. + * Includes position/size/angle sliders as gesture alternatives for accessibility. + */ +@Composable +fun ControlPanel( + params: BlurParameters, + onParamsChange: (BlurParameters) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier +) { + val currentParams by rememberUpdatedState(params) + val currentOnParamsChange by rememberUpdatedState(onParamsChange) + + Column( + modifier = modifier + .width(200.dp) + .clip(RoundedCornerShape(16.dp)) + .background(AppColors.OverlayDarker) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Reset button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = onReset, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = "Reset all parameters to defaults", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + + SliderControl( + label = "Blur", + value = params.blurAmount, + valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, + formatValue = { "${(it * 100).toInt()}%" }, + onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } + ) + + SliderControl( + label = "Falloff", + value = params.falloff, + valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, + formatValue = { "${(it * 100).toInt()}%" }, + onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) } + ) + + SliderControl( + label = "Size", + value = params.size, + valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE, + formatValue = { "${(it * 100).toInt()}%" }, + onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) } + ) + + if (params.mode == BlurMode.RADIAL) { + SliderControl( + label = "Shape", + value = params.aspectRatio, + valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT, + formatValue = { "%.1f:1".format(it) }, + onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } + ) + } + + SliderControl( + label = "Angle", + value = params.angle, + valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(), + formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" }, + onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) } + ) + } +} + +@Composable +private fun SliderControl( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + formatValue: (Float) -> String = { "${(it * 100).toInt()}%" }, + onValueChange: (Float) -> Unit +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Color.White, + fontSize = 12.sp + ) + Text( + text = formatValue(value), + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = AppColors.Accent, + activeTrackColor = AppColors.Accent, + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ), + modifier = Modifier + .height(24.dp) + .semantics { contentDescription = "$label: ${formatValue(value)}" } + ) + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt b/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt new file mode 100644 index 0000000..0c1d363 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt @@ -0,0 +1,248 @@ +package no.naiv.tiltshift.util + +import android.graphics.Bitmap + +/** + * Fast stack blur algorithm for CPU-based bitmap blurring. + * + * Used by the capture/gallery pipeline where GPU shaders aren't available. + * Stack blur is an approximation of Gaussian blur that runs in O(n) per pixel + * regardless of radius, making it suitable for large images. + */ +object StackBlur { + + /** + * Applies stack blur to a bitmap and returns a new blurred bitmap. + * The source bitmap is not modified. + * + * @param bitmap Source bitmap to blur. + * @param radius Blur radius (1-25). Larger = more blur. + * @return A new blurred bitmap. The caller owns it and must recycle it. + */ + fun blur(bitmap: Bitmap, radius: Int): Bitmap { + if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true) + + val w = bitmap.width + val h = bitmap.height + val pix = IntArray(w * h) + bitmap.getPixels(pix, 0, w, 0, 0, w, h) + + val wm = w - 1 + val hm = h - 1 + val wh = w * h + val div = radius + radius + 1 + + val r = IntArray(wh) + val g = IntArray(wh) + val b = IntArray(wh) + var rsum: Int + var gsum: Int + var bsum: Int + var x: Int + var y: Int + var i: Int + var p: Int + var yp: Int + var yi: Int + var yw: Int + val vmin = IntArray(maxOf(w, h)) + + var divsum = (div + 1) shr 1 + divsum *= divsum + val dv = IntArray(256 * divsum) + for (i2 in 0 until 256 * divsum) { + dv[i2] = (i2 / divsum) + } + + yi = 0 + yw = 0 + + val stack = Array(div) { IntArray(3) } + var stackpointer: Int + var stackstart: Int + var sir: IntArray + var rbs: Int + val r1 = radius + 1 + var routsum: Int + var goutsum: Int + var boutsum: Int + var rinsum: Int + var ginsum: Int + var binsum: Int + + // Horizontal pass + for (y2 in 0 until h) { + rinsum = 0 + ginsum = 0 + binsum = 0 + routsum = 0 + goutsum = 0 + boutsum = 0 + rsum = 0 + gsum = 0 + bsum = 0 + for (i2 in -radius..radius) { + p = pix[yi + minOf(wm, maxOf(i2, 0))] + sir = stack[i2 + radius] + sir[0] = (p and 0xff0000) shr 16 + sir[1] = (p and 0x00ff00) shr 8 + sir[2] = (p and 0x0000ff) + rbs = r1 - kotlin.math.abs(i2) + rsum += sir[0] * rbs + gsum += sir[1] * rbs + bsum += sir[2] * rbs + if (i2 > 0) { + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + } else { + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + } + } + stackpointer = radius + + for (x2 in 0 until w) { + r[yi] = dv[rsum] + g[yi] = dv[gsum] + b[yi] = dv[bsum] + + rsum -= routsum + gsum -= goutsum + bsum -= boutsum + + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + + routsum -= sir[0] + goutsum -= sir[1] + boutsum -= sir[2] + + if (y2 == 0) { + vmin[x2] = minOf(x2 + radius + 1, wm) + } + p = pix[yw + vmin[x2]] + + sir[0] = (p and 0xff0000) shr 16 + sir[1] = (p and 0x00ff00) shr 8 + sir[2] = (p and 0x0000ff) + + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + + rsum += rinsum + gsum += ginsum + bsum += binsum + + stackpointer = (stackpointer + 1) % div + sir = stack[(stackpointer) % div] + + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + + rinsum -= sir[0] + ginsum -= sir[1] + binsum -= sir[2] + + yi++ + } + yw += w + } + + // Vertical pass + for (x2 in 0 until w) { + rinsum = 0 + ginsum = 0 + binsum = 0 + routsum = 0 + goutsum = 0 + boutsum = 0 + rsum = 0 + gsum = 0 + bsum = 0 + yp = -radius * w + for (i2 in -radius..radius) { + yi = maxOf(0, yp) + x2 + + sir = stack[i2 + radius] + + sir[0] = r[yi] + sir[1] = g[yi] + sir[2] = b[yi] + + rbs = r1 - kotlin.math.abs(i2) + + rsum += r[yi] * rbs + gsum += g[yi] * rbs + bsum += b[yi] * rbs + + if (i2 > 0) { + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + } else { + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + } + + if (i2 < hm) { + yp += w + } + } + yi = x2 + stackpointer = radius + for (y2 in 0 until h) { + pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] + + rsum -= routsum + gsum -= goutsum + bsum -= boutsum + + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + + routsum -= sir[0] + goutsum -= sir[1] + boutsum -= sir[2] + + if (x2 == 0) { + vmin[y2] = minOf(y2 + r1, hm) * w + } + p = x2 + vmin[y2] + + sir[0] = r[p] + sir[1] = g[p] + sir[2] = b[p] + + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + + rsum += rinsum + gsum += ginsum + bsum += binsum + + stackpointer = (stackpointer + 1) % div + sir = stack[stackpointer] + + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + + rinsum -= sir[0] + ginsum -= sir[1] + binsum -= sir[2] + + yi += w + } + } + + val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + result.setPixels(pix, 0, w, 0, 0, w, h) + return result + } +}