Pith - varnish
package com.vgmlr.varnish

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale
import java.io.File
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

private const val CROP_PX = 2048
private const val MIN_PX = 48f
private const val G_L = 1
private const val G_T = 2
private const val G_R = 4
private const val G_B = 8
private const val G_MOVE = 16
private val TOUCH = 28.dp
private val HANDLE = 12.dp
private val SHADE = Color.Black.copy(alpha = 0.55f)
private val GUIDE = Color.White.copy(alpha = 0.35f)

@Composable
fun CropScreen(vm: VarnishViewModel, screen: Screen.Crop) {
    val context = LocalContext.current
    val file = remember(screen.path) { File(screen.path) }
    val dims by produceState<Pair<Int, Int>?>(null, screen.path) {
        value = withContext(Dispatchers.IO) { VarnishCodec.bounds(file) }
    }
    val request = remember(screen.path) {
        ImageRequest.Builder(context)
            .data(file)
            .size(CROP_PX)
            .precision(Precision.INEXACT)
            .scale(Scale.FIT)
            .build()
    }

    var box by remember { mutableStateOf(IntSize.Zero) }
    var l by rememberSaveable { mutableFloatStateOf(0f) }
    var t by rememberSaveable { mutableFloatStateOf(0f) }
    var r by rememberSaveable { mutableFloatStateOf(1f) }
    var b by rememberSaveable { mutableFloatStateOf(1f) }
    var busy by remember { mutableStateOf(false) }

    val fit: Rect? = remember(dims, box) {
        val d = dims ?: return@remember null
        if (box.width == 0 || box.height == 0) return@remember null
        val s = min(box.width / d.first.toFloat(), box.height / d.second.toFloat())
        val w = d.first * s
        val h = d.second * s
        Rect(Offset((box.width - w) / 2f, (box.height - h) / 2f), Size(w, h))
    }

    val top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + TOP_BAR_H + GAP
    val bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + EDGE

    Box(Modifier.fillMaxSize().background(PageBg)) {
        Box(
            Modifier
                .fillMaxSize()
                .padding(top = top, bottom = bottom, start = EDGE, end = EDGE)
                .onSizeChanged { box = it }
        ) {
            AsyncImage(
                model = request,
                contentDescription = null,
                contentScale = ContentScale.Fit,
                modifier = Modifier.fillMaxSize()
            )
            Canvas(
                Modifier
                    .fillMaxSize()
                    .pointerInput(fit) {
                        val f = fit ?: return@pointerInput
                        val touch = TOUCH.toPx()
                        var g = 0
                        detectDragGestures(
                            onDragStart = { p ->
                                val c = Rect(
                                    f.left + l * f.width, f.top + t * f.height,
                                    f.left + r * f.width, f.top + b * f.height
                                )
                                g = grip(p, c, touch)
                            },
                            onDragEnd = { g = 0 },
                            onDragCancel = { g = 0 }
                        ) { change, drag ->
                            if (g == 0) return@detectDragGestures
                            change.consume()
                            val dx = drag.x / f.width
                            val dy = drag.y / f.height
                            val mw = MIN_PX / f.width
                            val mh = MIN_PX / f.height
                            if (g == G_MOVE) {
                                val w = r - l
                                val h = b - t
                                val nl = (l + dx).coerceIn(0f, 1f - w)
                                val nt = (t + dy).coerceIn(0f, 1f - h)
                                l = nl
                                r = nl + w
                                t = nt
                                b = nt + h
                            } else {
                                if (g and G_L != 0) l = (l + dx).coerceIn(0f, r - mw)
                                if (g and G_R != 0) r = (r + dx).coerceIn(l + mw, 1f)
                                if (g and G_T != 0) t = (t + dy).coerceIn(0f, b - mh)
                                if (g and G_B != 0) b = (b + dy).coerceIn(t + mh, 1f)
                            }
                        }
                    }
            ) {
                val f = fit ?: return@Canvas
                val c = Rect(
                    f.left + l * f.width, f.top + t * f.height,
                    f.left + r * f.width, f.top + b * f.height
                )
                drawRect(SHADE, Offset(f.left, f.top), Size(f.width, c.top - f.top))
                drawRect(SHADE, Offset(f.left, c.bottom), Size(f.width, f.bottom - c.bottom))
                drawRect(SHADE, Offset(f.left, c.top), Size(c.left - f.left, c.height))
                drawRect(SHADE, Offset(c.right, c.top), Size(f.right - c.right, c.height))

                val sw = 1.dp.toPx()
                for (k in 1..2) {
                    val x = c.left + c.width * k / 3f
                    val y = c.top + c.height * k / 3f
                    drawLine(GUIDE, Offset(x, c.top), Offset(x, c.bottom), sw)
                    drawLine(GUIDE, Offset(c.left, y), Offset(c.right, y), sw)
                }

                drawRect(Color.White, c.topLeft, c.size, style = Stroke(2.dp.toPx()))

                val hs = HANDLE.toPx()
                listOf(c.topLeft, c.topRight, c.bottomLeft, c.bottomRight).forEach { p ->
                    drawRect(Color.White, Offset(p.x - hs / 2f, p.y - hs / 2f), Size(hs, hs))
                }
            }
        }

        TopBar(
            onBack = { vm.back() },
            trailing = {
                VButton(Icons.Default.Check, "Save", enabled = !busy && dims != null) {
                    val d = dims ?: return@VButton
                    val px = android.graphics.Rect(
                        (l * d.first).roundToInt(),
                        (t * d.second).roundToInt(),
                        (r * d.first).roundToInt(),
                        (b * d.second).roundToInt()
                    )
                    busy = true
                    vm.crop(screen.path, px) { busy = false }
                }
            }
        ) {
            TitlePlate(file.name, Modifier.weight(1f))
        }
    }
}

private fun grip(p: Offset, c: Rect, touch: Float): Int {
    val inX = p.x in (c.left - touch)..(c.right + touch)
    val inY = p.y in (c.top - touch)..(c.bottom + touch)
    val dl = abs(p.x - c.left)
    val dr = abs(p.x - c.right)
    val dt = abs(p.y - c.top)
    val db = abs(p.y - c.bottom)
    var g = 0
    if (inY && min(dl, dr) <= touch) g = g or if (dl <= dr) G_L else G_R
    if (inX && min(dt, db) <= touch) g = g or if (dt <= db) G_T else G_B
    if (g == 0 && c.contains(p)) g = G_MOVE
    return g
}