EditPage: New screen for editing an individual page

This commit is contained in:
Philipp Hasper
2026-02-21 14:06:01 +01:00
committed by Pierre-Yves Nicolas
parent dcc797785b
commit 2b63273168
16 changed files with 1992 additions and 6 deletions

View File

@@ -54,6 +54,8 @@ import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.edit.EditPageScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
@@ -62,7 +64,6 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.export.ExportActions
import org.fairscan.app.ui.screens.export.ExportEvent
import org.fairscan.app.ui.screens.export.ExportResult
@@ -177,6 +178,15 @@ class MainActivity : ComponentActivity() {
}
)
}
is Screen.Main.EditImage -> {
val pageIndex = (currentScreen as Screen.Main.EditImage).pageIndex
EditPageScreen(
pageId = documentUiState.document.pages[pageIndex].key.pageId,
imageRepository = imageRepository,
navigation = navigation,
onUpdatePageQuad = { id, quad, onComplete -> },
)
}
is Screen.Main.Document -> {
DocumentScreen (
uiState = documentUiState,
@@ -457,6 +467,7 @@ class MainActivity : ComponentActivity() {
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
toEditImageScreen = { pageIndex -> viewModel.navigateTo(Screen.Main.EditImage(pageIndex)) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },

View File

@@ -17,6 +17,7 @@ package org.fairscan.app.ui
sealed class Screen {
sealed class Main : Screen() {
object Camera : Main()
data class EditImage(val pageIndex: Int) : Main()
data class Document(val initialPage: Int = 0) : Main()
object Export : Main()
}
@@ -29,6 +30,7 @@ sealed class Screen {
data class Navigation(
val toCameraScreen: () -> Unit,
val toEditImageScreen: (Int) -> Unit,
val toDocumentScreen: () -> Unit,
val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit,
@@ -62,6 +64,7 @@ data class NavigationState private constructor(val stack: List<Screen>, val root
root -> this // Back handled by system
is Screen.Main.Camera -> this // Back handled by system
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document(initialPage = (current as Screen.Main.EditImage).pageIndex)))
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Overlay -> copy(stack = stack.dropLast(1))
}

View File

@@ -25,7 +25,7 @@ import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}, {})
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
}
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {

View File

@@ -14,6 +14,7 @@
*/
package org.fairscan.app.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
FilledIconButton (
onClick = onClick,
colors = IconButtonDefaults.outlinedIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
contentColor = MaterialTheme.colorScheme.primary
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.primary,
disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
),
modifier = modifier.size(40.dp)
modifier = modifier.size(40.dp),
enabled = enabled
) {
Icon(
imageVector = icon,

View File

@@ -0,0 +1,430 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.fairscan.app.R
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.ImageTransformations
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.AppOverflowMenu
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.components.ConfirmationDialog
import org.fairscan.app.ui.components.MainActionButton
import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import java.io.File
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPageScreen(
pageId: String,
imageRepository: ImageRepository,
navigation: Navigation,
onUpdatePageQuad: (String, Quad, onComplete: () -> Unit) -> Unit,
onReportProblem: () -> Unit = {},
) {
val showDiscardChangesDialog = rememberSaveable { mutableStateOf(false) }
val state = remember { EditPageScreenState() }
val quadHandler = remember { QuadEditingHandler() }
val handleBack = {
if (state.hasUnsavedChanges()) {
showDiscardChangesDialog.value = true
} else {
navigation.back()
}
}
BackHandler { handleBack() }
val isPreview = LocalInspectionMode.current
if (isPreview) {
val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input ->
BitmapFactory.decodeStream(input)
}
state.bitmap = dummyImage
state.setInitialQuad(Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9)))
}
val totalRotation = remember { mutableStateOf(Rotation.R0) }
LaunchedEffect(pageId) {
val metadata = imageRepository.getPageMetadata(pageId)
val baseRotation = metadata?.baseRotation ?: Rotation.R0
val manualRotation = imageRepository.getManualRotation(pageId)
val rotation = baseRotation.add(manualRotation)
totalRotation.value = rotation
val bitmap = withContext(Dispatchers.IO) {
val sourceJpegBytes = imageRepository.sourceJpegBytes(pageId)
if (sourceJpegBytes != null) {
val original = BitmapFactory.decodeByteArray(sourceJpegBytes, 0, sourceJpegBytes.size)
if (original != null && rotation != Rotation.R0) {
// Adjust the displayed bitmap's rotation to what is in the metadata
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
val rotated = android.graphics.Bitmap.createBitmap(
original, 0, 0, original.width, original.height, matrix, true
)
if (rotated !== original) {
original.recycle()
}
rotated
} else {
original
}
} else null
}
state.bitmap = bitmap // assigned on the main thread after withContext returns
if (metadata?.normalizedQuad != null) {
// Rotate the quad to match the rotated bitmap display
val rotatedQuad = metadata.normalizedQuad.rotate90(
rotation.degrees / 90,
ImageSize(1, 1)
)
state.setInitialQuad(rotatedQuad)
}
}
val isLandscape = isLandscape(LocalConfiguration.current)
Scaffold { _ ->
Box(modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
) {
state.bitmap?.let { bmp ->
val imageBitmap = remember(bmp) { bmp.asImageBitmap() }
Box(
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
Image(
bitmap = imageBitmap,
contentDescription = "Image to edit",
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
state.containerSize = coordinates.size
},
contentScale = ContentScale.Fit,
)
DragQuadOverlay(state, quadHandler, bmp)
}
}
BackButton(
onClick = handleBack,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing)
)
AppOverflowMenu(
navigation,
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing),
)
DragMagnifyingGlass(state)
ActionButtons(
modifier = Modifier
.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
.padding(16.dp)
.windowInsetsPadding(WindowInsets.safeDrawing),
onConfirm = {
val quad = state.editableQuad
if (quad != null) {
// Reverse the total rotation to get back to original source image coordinates
val rotateIterations = (4 - totalRotation.value.degrees / 90) % 4
val originalQuad = quad.rotate90(rotateIterations, ImageSize(1, 1))
onUpdatePageQuad(pageId, originalQuad) {
navigation.back()
}
state.setInitialQuad(quad)
} else {
navigation.back()
}
}
)
}
}
if (showDiscardChangesDialog.value) {
ConfirmationDialog(
title = stringResource(R.string.discard_changes),
message = stringResource(R.string.discard_changes_warning),
showDialog = showDiscardChangesDialog
) {
state.revertToInitial()
navigation.back()
}
}
}
@Composable
private fun ActionButtons(
modifier: Modifier,
onConfirm: () -> Unit
) {
MainActionButton(
onClick = onConfirm,
text = stringResource(R.string.confirm),
icon = Icons.Filled.Check,
iconDescription = stringResource(R.string.confirm),
modifier = modifier
)
}
@Composable
private fun DragQuadOverlay(
state: EditPageScreenState,
quadHandler: QuadEditingHandler,
bmp: android.graphics.Bitmap
) {
if (state.editableQuad == null || state.containerSize == null) return
val containerSize = state.containerSize!!
val displaySize = QuadCoordinateUtils.calculateDisplaySize(bmp.width, bmp.height, containerSize)
val liftWiggleThresholdPx = with(LocalDensity.current) {
EditPageScreenState.LIFT_WIGGLE_MAX_DISTANCE.toPx()
}
QuadOverlay(
quad = state.editableQuad!!,
containerSize = containerSize,
displaySize = displaySize,
modifier = Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { startPos ->
val quad = state.editableQuad ?: return@detectDragGestures
state.dragPosition = startPos
// Prefer the index stored at raw touch-down (exact touch position,
// before slop). Fall back to re-detecting at the slop position only
// when the raw-touch handler missed the down event.
val cornerIndex = if (state.touchDownCornerIndex >= 0) {
state.touchDownCornerIndex
} else {
quadHandler.findTouchedCorner(startPos, quad, containerSize, displaySize)
}
if (cornerIndex >= 0) {
state.startCornerDrag(cornerIndex)
}
},
onDragEnd = {
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
state.endDrag()
state.onTouchUp()
},
onDragCancel = {
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
state.endDrag()
state.onTouchUp()
},
onDrag = { change, dragAmount ->
// change.consume() is intentionally omitted: detectDragGestures
// already calls it.consume() internally after this callback returns.
state.dragPosition = change.position
val quad = state.editableQuad ?: return@detectDragGestures
state.recordDragStep(quad, dragAmount)
val normalizedDelta = QuadCoordinateUtils.screenDeltaToNormalized(
dragAmount, displaySize
)
when {
state.draggedCornerIndex >= 0 -> {
state.updateQuad(
quadHandler.updateQuadCorner(
quad, state.draggedCornerIndex, normalizedDelta
)
)
}
}
}
)
}
// Second pointer-input: fires immediately on press (before touch slop)
// so the loupe appears as soon as the finger touches a handle.
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val quad = state.editableQuad
if (quad != null) {
val cIdx = quadHandler.findTouchedCorner(down.position, quad, containerSize, displaySize)
if (cIdx >= 0) {
state.onTouchDown(down.position, cIdx)
}
}
// For a tap (no drag): waitForUpOrCancellation() sees the UP event and
// returns it, so we call onTouchUp() here.
// For a drag: detectDragGestures consumes move events, causing
// waitForUpOrCancellation() to return null. We do NOT call onTouchUp()
// here; onDragEnd / onDragCancel above handle that instead.
if (waitForUpOrCancellation() != null) {
state.onTouchUp()
}
}
}
)
}
@Composable
private fun DragMagnifyingGlass(state: EditPageScreenState) {
// showLoupe becomes true immediately on touch-down and stays true for
// one additional second after the finger is lifted.
val showLoupe = remember { mutableStateOf(false) }
// Remember the last valid focus position so the loupe keeps rendering
// correctly during the 1-second fade-out (when dragged indices are reset).
val lastKnownFocusPosition = remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(state.isTouching) {
if (state.isTouching) {
showLoupe.value = true
} else {
delay(1_000)
showLoupe.value = false
}
}
if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return
val bmp = state.bitmap ?: return
val containerSize = state.containerSize!!
val displaySize = QuadCoordinateUtils.calculateDisplaySize(
bmp.width, bmp.height, containerSize
)
val quad = state.editableQuad
// Resolve which corner index to focus on.
// Priority: active drag > pre-drag touch-down > nothing (fade-out phase).
val activeCornerIndex = state.draggedCornerIndex.takeIf { it >= 0 }
?: state.touchDownCornerIndex.takeIf { it >= 0 }
val focusPosition = if (quad != null) {
when {
activeCornerIndex != null -> {
val corner = when (activeCornerIndex) {
0 -> quad.topLeft
1 -> quad.topRight
2 -> quad.bottomRight
3 -> quad.bottomLeft
else -> null
}
corner?.let {
QuadCoordinateUtils.normalizedToScreen(it, containerSize, displaySize)
}
}
else -> null
}
} else null
// Keep the last known focus position so it's still valid after endDrag() resets the indices.
if (focusPosition != null) lastKnownFocusPosition.value = focusPosition
// On the very first touch the drag indices are not set yet and lastKnownFocusPosition
// has never been populated, so fall back to dragPosition (the finger is on the handle).
val effectiveFocusPosition = focusPosition ?: lastKnownFocusPosition.value ?: state.dragPosition ?: return
MagnifyingGlass(
bitmap = bmp,
fingerPosition = state.dragPosition!!,
focusPosition = effectiveFocusPosition,
containerSize = containerSize,
displaySize = displaySize,
quad = state.editableQuad,
)
}
@Composable
@Preview(showSystemUi = true)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true)
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
@Preview(name = "RTL", locale = "ar", showSystemUi = true)
fun EditPageScreenPreview() {
FairScanTheme {
// Minimal no-op ImageTransformations implementation used only for preview.
val dummyTransformations = object : ImageTransformations {
override fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) = Unit
override fun resize(inputFile: File, outputFile: File, maxSize: Int) = Unit
}
// Use a temporary directory for the repository in preview.
val tempDir = File(System.getProperty("java.io.tmpdir") ?: "/tmp")
val dummyImageRepo = ImageRepository(tempDir, dummyTransformations, 128)
EditPageScreen(
pageId = "preview-page-id",
imageRepository = dummyImageRepo,
navigation = dummyNavigation(),
onUpdatePageQuad = { _, _, onComplete -> onComplete() }
)
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Quad
class EditPageScreenState {
companion object {
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
const val LIFT_WIGGLE_WINDOW_MS = 70L
}
var bitmap by mutableStateOf<android.graphics.Bitmap?>(null)
var containerSize by mutableStateOf<IntSize?>(null)
var editableQuad by mutableStateOf<Quad?>(null)
var draggedCornerIndex by mutableIntStateOf(-1)
var dragPosition by mutableStateOf<Offset?>(null)
/** True from the moment the finger touches a drag handle until it is lifted. */
var isTouching by mutableStateOf(false)
/**
* Corner / edge index detected at the raw touch-down (before touch-slop).
* Carried into [startCornerDrag] so that the slop-adjusted
* position in onDragStart cannot miss the handle.
*/
var touchDownCornerIndex by mutableIntStateOf(-1)
private var quadBeforeDrag: Quad? = null
private var quadBeforeLastDragStep: Quad? = null
private var lastDragStepDistancePx: Float = Float.MAX_VALUE
private var lastDragStepAtMs: Long = 0L
private var initialQuad: Quad? = null
fun updateQuad(newQuad: Quad) {
editableQuad = newQuad
}
fun startCornerDrag(cornerIndex: Int) {
quadBeforeDrag = editableQuad
draggedCornerIndex = cornerIndex
clearLastDragStep()
}
fun recordDragStep(previousQuad: Quad, dragAmount: Offset, eventTimeMs: Long = System.currentTimeMillis()) {
quadBeforeLastDragStep = previousQuad
lastDragStepDistancePx = dragAmount.getDistance()
lastDragStepAtMs = eventTimeMs
}
fun rollbackLastDragStepIfLikelyLiftWiggle(
maxDistancePx: Float,
nowMs: Long = System.currentTimeMillis()
) {
if (quadBeforeLastDragStep == null) return
val isRecent = nowMs - lastDragStepAtMs <= LIFT_WIGGLE_WINDOW_MS
val isSmall = lastDragStepDistancePx <= maxDistancePx
if (isRecent && isSmall) {
editableQuad = quadBeforeLastDragStep
}
}
fun endDrag() {
quadBeforeDrag = null
clearLastDragStep()
draggedCornerIndex = -1
// dragPosition is intentionally kept so the loupe can still render
// during its 1-second fade-out after the finger is lifted.
}
private fun clearLastDragStep() {
quadBeforeLastDragStep = null
lastDragStepDistancePx = Float.MAX_VALUE
lastDragStepAtMs = 0L
}
/**
* Called as soon as the finger touches a drag handle (before touch-slop),
* so the loupe is shown immediately.
* [cornerIndex] is the handle index found at the exact
* touch position; it is stored so that drag start handling can use it even
* if the slop-adjusted position drifts outside the hit-test radius.
*/
fun onTouchDown(position: Offset, cornerIndex: Int = -1) {
isTouching = true
dragPosition = position
touchDownCornerIndex = cornerIndex
}
/** Called when the finger is lifted; triggers the loupe fade-out delay. */
fun onTouchUp() {
isTouching = false
touchDownCornerIndex = -1
}
fun isDragging(): Boolean = draggedCornerIndex >= 0
fun setInitialQuad(quad: Quad) {
initialQuad = quad
editableQuad = quad
}
fun hasUnsavedChanges(): Boolean {
return editableQuad != initialQuad
}
fun revertToInitial() {
editableQuad = initialQuad
}
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.fairscan.imageprocessing.Quad
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
private val LOUPE_BORDER_WIDTH = 3.dp
private const val ZOOM_FACTOR = 3f
/**
* Layout parameters for the magnifying-glass loupe.
*
* Use `LoupeLayoutConfig<Dp>` in Compose UI code and [toPx] to convert to
* `LoupeLayoutConfig<Float>` for pixel-level calculations.
*/
data class LoupeLayoutConfig<T>(
/** Radius of the loupe circle */
val loupeRadius: T,
/** Gap between the finger and the nearest edge of the loupe */
val verticalOffset: T,
/** Minimum space between the loupe and the screen edge */
val screenMargin: T,
) {
companion object {
val Default = LoupeLayoutConfig(
loupeRadius = 60.dp,
verticalOffset = 40.dp,
screenMargin = 8.dp,
)
}
}
/** Convert a dp-valued config to pixels using the current [LocalDensity]. */
@Composable
internal fun LoupeLayoutConfig<Dp>.toPx(): LoupeLayoutConfig<Float> {
val density = LocalDensity.current
return with(density) {
LoupeLayoutConfig(
loupeRadius = loupeRadius.toPx(),
verticalOffset = verticalOffset.toPx(),
screenMargin = screenMargin.toPx(),
)
}
}
/**
* Displays a magnifying glass / loupe showing a zoomed-in patch of [bitmap]
* centred around [focusPosition].
*
* Positioning rules:
* 1. By default, the loupe is placed **above** the finger.
* 2. If there is not enough room above, it moves to the **left** of the finger.
* 3. If there is not enough room on the left either, it moves to the **right**.
*
* @param bitmap The full source bitmap (original image).
* @param fingerPosition Current finger position in screen (container) coordinates, used for loupe placement.
* @param focusPosition The exact point to zoom into (e.g. corner or edge midpoint) in screen coordinates.
* @param containerSize Size of the full-screen container.
* @param displaySize Size of the image as rendered (letterboxed inside the container).
*/
@Composable
fun MagnifyingGlass(
bitmap: Bitmap,
fingerPosition: Offset,
focusPosition: Offset,
containerSize: IntSize,
displaySize: IntSize,
quad: Quad? = null,
configDp: LoupeLayoutConfig<Dp> = LoupeLayoutConfig.Default,
) {
val density = LocalDensity.current
val configPx = configDp.toPx()
val borderWidth = with(density) { LOUPE_BORDER_WIDTH.toPx() }
// compute loupe centre position
val loupeCenter = computeLoupeCenter(
dragPosition = fingerPosition,
configPx = configPx,
containerWidth = containerSize.width.toFloat(),
)
// compute the bitmap region to sample
val imageOffset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
// Focus position mapped to bitmap pixel coordinates
val bitmapX = ((focusPosition.x - imageOffset.width) / displaySize.width * bitmap.width)
.coerceIn(0f, (bitmap.width - 1).toFloat())
val bitmapY = ((focusPosition.y - imageOffset.height) / displaySize.height * bitmap.height)
.coerceIn(0f, (bitmap.height - 1).toFloat())
// How many bitmap pixels the loupe shows in each direction
val bitmapRegionHalf = (bitmap.width / displaySize.width.toFloat()) * configPx.loupeRadius / ZOOM_FACTOR
val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() }
val borderColor = MaterialTheme.colorScheme.primary
val quadLineColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
val backgroundColor = MaterialTheme.colorScheme.background
Canvas(
modifier = Modifier
.size(configDp.loupeRadius * 2)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
IntOffset(
(loupeCenter.x - configPx.loupeRadius).roundToInt(),
(loupeCenter.y - configPx.loupeRadius).roundToInt()
)
)
}
}
) {
val loupeDiameter = configPx.loupeRadius * 2
val circlePath = Path().apply {
addOval(
androidx.compose.ui.geometry.Rect(0f, 0f, loupeDiameter, loupeDiameter)
)
}
clipPath(circlePath) {
// Fill background so areas outside the image are opaque
drawRect(color = backgroundColor)
// Source rect in bitmap coordinates
val srcLeft = (bitmapX - bitmapRegionHalf).toInt().coerceAtLeast(0)
val srcTop = (bitmapY - bitmapRegionHalf).toInt().coerceAtLeast(0)
val srcRight = (bitmapX + bitmapRegionHalf).toInt().coerceAtMost(bitmap.width)
val srcBottom = (bitmapY + bitmapRegionHalf).toInt().coerceAtMost(bitmap.height)
if (srcRight > srcLeft && srcBottom > srcTop) {
// Destination offset compensate when the source rect was clamped
val dstOffsetX = ((srcLeft - (bitmapX - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
val dstOffsetY = ((srcTop - (bitmapY - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
val dstWidth = ((srcRight - srcLeft) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
val dstHeight = ((srcBottom - srcTop) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
drawImage(
image = imageBitmap,
srcOffset = IntOffset(srcLeft, srcTop),
srcSize = IntSize(srcRight - srcLeft, srcBottom - srcTop),
dstOffset = IntOffset(dstOffsetX.toInt(), dstOffsetY.toInt()),
dstSize = IntSize(dstWidth, dstHeight),
)
}
// Draw quad overlay and edges inside the loupe
// Convert each normalized quad corner to bitmap-pixel coords,
// then to loupe-local coords using the same mapping as the bitmap sampling.
if (quad != null) {
val bitmapOriginX = bitmapX - bitmapRegionHalf
val bitmapOriginY = bitmapY - bitmapRegionHalf
val scale = loupeDiameter / (2 * bitmapRegionHalf)
fun normalizedToLoupe(nx: Double, ny: Double): Offset {
val bx = (nx * bitmap.width).toFloat()
val by = (ny * bitmap.height).toFloat()
return Offset(
(bx - bitmapOriginX) * scale,
(by - bitmapOriginY) * scale
)
}
val loupeCorners = listOf(
normalizedToLoupe(quad.topLeft.x, quad.topLeft.y),
normalizedToLoupe(quad.topRight.x, quad.topRight.y),
normalizedToLoupe(quad.bottomRight.x, quad.bottomRight.y),
normalizedToLoupe(quad.bottomLeft.x, quad.bottomLeft.y),
)
// Striped overlay outside the quad to distinguish document from background
val quadPath = Path().apply {
moveTo(loupeCorners[0].x, loupeCorners[0].y)
for (corner in loupeCorners.drop(1)) {
lineTo(corner.x, corner.y)
}
close()
}
clipPath(quadPath, clipOp = ClipOp.Difference) {
// Clip to actual image bounds so stripes only appear over
// image content, not in the background area near edges.
val imgLeft = -bitmapOriginX * scale
val imgTop = -bitmapOriginY * scale
val imgRight = (bitmap.width - bitmapOriginX) * scale
val imgBottom = (bitmap.height - bitmapOriginY) * scale
clipRect(
left = imgLeft, top = imgTop,
right = imgRight, bottom = imgBottom
) {
val stripeSpacing = 50f
val stripeWidth = 10f
val stripeColor = Color.Gray.copy(alpha = 0.35f)
// Stripes are placed at fixed positions in bitmap coordinate
// space so they scroll with the image as the loupe pans.
val originShift = (bitmapOriginX + bitmapOriginY) * scale
val kMin = floor(originShift / stripeSpacing).toInt() - 1
val kMax = ceil((originShift + 2f * loupeDiameter) / stripeSpacing).toInt() + 1
for (k in kMin..kMax) {
val loupeSum = k * stripeSpacing - originShift
drawLine(
color = stripeColor,
start = Offset(loupeSum, 0f),
end = Offset(0f, loupeSum),
strokeWidth = stripeWidth,
)
}
}
}
// Draw quad edge lines on top
for (i in 0 until 4) {
drawLine(
color = quadLineColor,
start = loupeCorners[i],
end = loupeCorners[(i + 1) % 4],
strokeWidth = 3f
)
}
}
}
// Border
drawCircle(
color = borderColor,
radius = configPx.loupeRadius - borderWidth / 2,
center = Offset(configPx.loupeRadius, configPx.loupeRadius),
style = Stroke(width = borderWidth)
)
}
}
/**
* Decides where the loupe centre should be.
*
* Priority:
* 1. Above the finger (centred horizontally, clamped to screen edges).
* 2. If no vertical room -> to the left.
* 3. If no room on the left -> to the right.
*/
internal fun computeLoupeCenter(
dragPosition: Offset,
configPx: LoupeLayoutConfig<Float>,
containerWidth: Float,
): Offset {
val loupeRadius = configPx.loupeRadius
val verticalOffset = configPx.verticalOffset
val screenMargin = configPx.screenMargin
// Try above
val aboveCenterY = dragPosition.y - verticalOffset - loupeRadius
if (aboveCenterY - loupeRadius >= screenMargin) {
// Enough room above -> place centred horizontally on the finger, clamped to screen edges
val cx = dragPosition.x.coerceIn(screenMargin + loupeRadius, containerWidth - screenMargin - loupeRadius)
return Offset(cx, aboveCenterY)
}
// Not enough room above -> try left
val leftCenterX = dragPosition.x - verticalOffset - loupeRadius
if (leftCenterX - loupeRadius >= screenMargin) {
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
return Offset(leftCenterX, cy)
}
// Not enough room on the left -> place right
val rightCenterX = dragPosition.x + verticalOffset + loupeRadius
val cx = rightCenterX.coerceAtMost(containerWidth - screenMargin - loupeRadius)
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
return Offset(cx, cy)
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
object QuadCoordinateUtils {
fun calculateDisplaySize(
bitmapWidth: Int,
bitmapHeight: Int,
containerSize: IntSize
): IntSize {
val imageAspectRatio = bitmapWidth.toFloat() / bitmapHeight.toFloat()
val containerAspectRatio = containerSize.width / containerSize.height.toFloat()
return if (imageAspectRatio > containerAspectRatio) {
IntSize(containerSize.width, (containerSize.width / imageAspectRatio).toInt())
} else {
IntSize((containerSize.height * imageAspectRatio).toInt(), containerSize.height)
}
}
fun normalizedToScreen(point: Point, containerSize: IntSize, displaySize: IntSize): Offset {
val offsetX = (containerSize.width - displaySize.width) / 2
val offsetY = (containerSize.height - displaySize.height) / 2
return Offset(
x = (point.x * displaySize.width).toFloat() + offsetX,
y = (point.y * displaySize.height).toFloat() + offsetY
)
}
fun screenDeltaToNormalized(delta: Offset, displaySize: IntSize): Offset {
return Offset(
x = delta.x / displaySize.width,
y = delta.y / displaySize.height
)
}
fun getImageOffset(containerSize: IntSize, displaySize: IntSize): IntSize {
return IntSize(
(containerSize.width - displaySize.width) / 2,
(containerSize.height - displaySize.height) / 2
)
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
class QuadEditingHandler {
companion object {
const val CORNER_RADIUS = 40f
const val CORNER_TOUCH_RADIUS = 90f
}
fun findTouchedCorner(
touchPos: Offset,
quad: Quad,
containerSize: IntSize,
displaySize: IntSize
): Int {
return findTouchedCornerCandidates(touchPos, quad, containerSize, displaySize)
.firstOrNull() ?: -1
}
fun findTouchedCornerCandidates(
touchPos: Offset,
quad: Quad,
containerSize: IntSize,
displaySize: IntSize
): List<Int> {
val corners = getCornerPositions(quad, containerSize, displaySize)
return corners
.mapIndexed { index, corner -> index to (touchPos - corner).getDistance() }
.filter { (_, distance) -> distance < CORNER_TOUCH_RADIUS }
.sortedBy { (_, distance) -> distance }
.map { (index, _) -> index }
}
fun updateQuadCorner(quad: Quad, cornerIndex: Int, delta: Offset): Quad {
val normalizedDelta = Point(delta.x.toDouble(), delta.y.toDouble())
val candidate = when (cornerIndex) {
0 -> quad.copy(topLeft = clampPoint(quad.topLeft + normalizedDelta))
1 -> quad.copy(topRight = clampPoint(quad.topRight + normalizedDelta))
2 -> quad.copy(bottomRight = clampPoint(quad.bottomRight + normalizedDelta))
3 -> quad.copy(bottomLeft = clampPoint(quad.bottomLeft + normalizedDelta))
else -> quad
}
return if (candidate.isConvex()) candidate else quad
}
private fun getCornerPositions(quad: Quad, containerSize: IntSize, displaySize: IntSize): List<Offset> {
return listOf(
QuadCoordinateUtils.normalizedToScreen(quad.topLeft, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.topRight, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.bottomRight, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.bottomLeft, containerSize, displaySize)
)
}
private fun clampPoint(point: Point): Point {
return Point(
point.x.coerceIn(0.0, 1.0),
point.y.coerceIn(0.0, 1.0)
)
}
private operator fun Point.plus(other: Point): Point {
return Point(this.x + other.x, this.y + other.y)
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.scaledTo
@Composable
fun QuadOverlay(
quad: Quad,
containerSize: IntSize,
displaySize: IntSize,
modifier: Modifier = Modifier
) {
val quadColor = MaterialTheme.colorScheme.primary
val handleColor = quadColor.copy(alpha = 0.5f)
Canvas(modifier = modifier.fillMaxSize()) {
val scaledQuad = quad.scaledTo(
fromWidth = 1,
fromHeight = 1,
toWidth = displaySize.width,
toHeight = displaySize.height
)
val offset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
val corners = listOf(
scaledQuad.topLeft.toOffset(),
scaledQuad.topRight.toOffset(),
scaledQuad.bottomRight.toOffset(),
scaledQuad.bottomLeft.toOffset()
).map { it.copy(x = it.x + offset.width, y = it.y + offset.height) }
// Draw edges
for (i in 0 until 4) {
drawLine(
color = quadColor,
start = corners[i],
end = corners[(i + 1) % 4],
strokeWidth = 10.0f
)
}
// Draw corner handles
corners.forEach { corner ->
drawCircle(
color = handleColor,
radius = QuadEditingHandler.CORNER_RADIUS,
center = corner
)
}
}
}
fun Point.toOffset() = Offset(x.toFloat(), y.toFloat())

View File

@@ -7,6 +7,7 @@
<string name="camera_permission_denied">Camera permission was denied</string>
<string name="camera_permission_rationale">The app requires camera access to scan documents. Captured images are stored only on this device and will be deleted when you close the current document.</string>
<string name="cancel">Cancel</string>
<string name="confirm">Confirm</string>
<string name="change_directory">Change folder</string>
<string name="clear_text">Clear text</string>
<string name="color_mode">Filter</string>
@@ -21,6 +22,8 @@
<string name="delete_page">Delete page</string>
<string name="delete_page_warning">Do you want to delete this page?</string>
<string name="developer">Developer</string>
<string name="discard_changes">Discard changes</string>
<string name="discard_changes_warning">You have unsaved changes. Do you want to discard them?</string>
<string name="discard_scan">Discard scan</string>
<string name="download_dirname">Downloads</string>
<string name="error">Error: %1$s</string>

View File

@@ -0,0 +1,356 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import org.assertj.core.api.Assertions.assertThat
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.junit.Test
class EditPageScreenStateTest {
companion object {
private const val wiggleThresholdPx = 8f
}
private val testQuad = Quad(
topLeft = Point(0.1, 0.1),
topRight = Point(0.9, 0.1),
bottomRight = Point(0.9, 0.9),
bottomLeft = Point(0.1, 0.9)
)
private val updatedQuad = Quad(
topLeft = Point(0.2, 0.2),
topRight = Point(0.8, 0.2),
bottomRight = Point(0.8, 0.8),
bottomLeft = Point(0.2, 0.8)
)
@Test
fun initialState_hasCorrectDefaults() {
val state = EditPageScreenState()
assertThat(state.bitmap).isNull()
assertThat(state.containerSize).isNull()
assertThat(state.editableQuad).isNull()
assertThat(state.draggedCornerIndex).isEqualTo(-1)
assertThat(state.isDragging()).isFalse()
// Touch / loupe state
assertThat(state.isTouching).isFalse()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
assertThat(state.dragPosition).isNull()
}
@Test
fun quadUpdates_workCorrectly() {
val state = EditPageScreenState()
state.updateQuad(testQuad)
assertThat(state.editableQuad).isEqualTo(testQuad)
state.updateQuad(updatedQuad)
assertThat(state.editableQuad).isEqualTo(updatedQuad)
}
@Test
fun cornerDragging_managesStateCorrectly() {
val state = EditPageScreenState()
// Corner drag starts correctly
for (i in 0 until 4) {
state.startCornerDrag(i)
assertThat(state.draggedCornerIndex).isEqualTo(i)
assertThat(state.isDragging()).isTrue()
}
// End drag resets all
state.startCornerDrag(2)
state.endDrag()
assertThat(state.draggedCornerIndex).isEqualTo(-1)
assertThat(state.isDragging()).isFalse()
// End drag when not dragging stays in non-dragging state
state.endDrag()
assertThat(state.isDragging()).isFalse()
assertThat(state.draggedCornerIndex).isEqualTo(-1)
}
@Test
fun fullDragCycle_preservesQuadAfterDragEnds() {
val state = EditPageScreenState()
assertThat(state.isDragging()).isFalse()
state.startCornerDrag(1)
assertThat(state.isDragging()).isTrue()
state.updateQuad(updatedQuad)
assertThat(state.editableQuad).isEqualTo(updatedQuad)
assertThat(state.isDragging()).isTrue()
state.endDrag()
assertThat(state.isDragging()).isFalse()
assertThat(state.editableQuad).isEqualTo(updatedQuad)
}
// ── onTouchDown ──────────────────────────────────────────────────────────
@Test
fun onTouchDown_setsIsTouchingAndDragPosition() {
val state = EditPageScreenState()
val pos = Offset(100f, 200f)
state.onTouchDown(pos)
assertThat(state.isTouching).isTrue()
assertThat(state.dragPosition).isEqualTo(pos)
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
}
@Test
fun onTouchDown_withCornerIndex_storesCornerIndex() {
val state = EditPageScreenState()
state.onTouchDown(Offset(50f, 50f), cornerIndex = 2)
assertThat(state.isTouching).isTrue()
assertThat(state.touchDownCornerIndex).isEqualTo(2)
}
@Test
fun onTouchDown_withEdgeIndex_storesEdgeIndex() {
// Edge index no longer exists; onTouchDown with no corner index leaves touchDownCornerIndex as -1.
val state = EditPageScreenState()
state.onTouchDown(Offset(50f, 50f))
assertThat(state.isTouching).isTrue()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
}
@Test
fun onTouchDown_overwritesPreviousTouchDown() {
val state = EditPageScreenState()
state.onTouchDown(Offset(10f, 10f), cornerIndex = 0)
state.onTouchDown(Offset(50f, 50f), cornerIndex = 3)
assertThat(state.dragPosition).isEqualTo(Offset(50f, 50f))
assertThat(state.touchDownCornerIndex).isEqualTo(3)
}
// ── onTouchUp ────────────────────────────────────────────────────────────
@Test
fun onTouchUp_clearsIsTouchingAndTouchDownIndices() {
val state = EditPageScreenState()
state.onTouchDown(Offset(100f, 200f), cornerIndex = 1)
state.onTouchUp()
assertThat(state.isTouching).isFalse()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
}
@Test
fun onTouchUp_preservesDragPosition() {
val state = EditPageScreenState()
val pos = Offset(100f, 200f)
state.onTouchDown(pos, cornerIndex = 1)
state.onTouchUp()
// dragPosition must survive so the loupe can still render during its fade-out delay.
assertThat(state.dragPosition).isEqualTo(pos)
}
@Test
fun onTouchUp_whenNotTouching_isIdempotent() {
val state = EditPageScreenState()
state.onTouchUp()
assertThat(state.isTouching).isFalse()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
}
// ── endDrag ──────────────────────────────────────────────────────────────
@Test
fun endDrag_preservesDragPosition() {
val state = EditPageScreenState()
state.setInitialQuad(testQuad)
val pos = Offset(100f, 200f)
state.onTouchDown(pos, cornerIndex = 0)
state.startCornerDrag(0)
state.updateQuad(updatedQuad)
state.endDrag()
// dragPosition must NOT be nulled so the loupe stays visible during the 1 s fade-out.
assertThat(state.dragPosition).isEqualTo(pos)
assertThat(state.draggedCornerIndex).isEqualTo(-1)
}
@Test
fun endDrag_doesNotResetTouchDownIndices() {
val state = EditPageScreenState()
state.setInitialQuad(testQuad)
state.onTouchDown(Offset(100f, 200f), cornerIndex = 2)
state.startCornerDrag(2)
state.endDrag()
// touchDownCornerIndex is owned by onTouchUp(), not endDrag().
assertThat(state.touchDownCornerIndex).isEqualTo(2)
}
@Test
fun rollbackLastDragStepIfLikelyLiftWiggle_revertsRecentSmallStep() {
val state = EditPageScreenState()
state.updateQuad(testQuad)
state.startCornerDrag(0)
state.recordDragStep(testQuad, Offset(3f, 2f), eventTimeMs = 1_000)
state.updateQuad(updatedQuad)
state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_030)
assertThat(state.editableQuad).isEqualTo(testQuad)
}
@Test
fun rollbackLastDragStepIfLikelyLiftWiggle_keepsLargeStep() {
val state = EditPageScreenState()
state.updateQuad(testQuad)
state.startCornerDrag(0)
state.recordDragStep(testQuad, Offset(20f, 0f), eventTimeMs = 1_000)
state.updateQuad(updatedQuad)
state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_030)
assertThat(state.editableQuad).isEqualTo(updatedQuad)
}
@Test
fun rollbackLastDragStepIfLikelyLiftWiggle_keepsOldSmallStep() {
val state = EditPageScreenState()
state.updateQuad(testQuad)
state.startCornerDrag(0)
state.recordDragStep(testQuad, Offset(3f, 2f), eventTimeMs = 1_000)
state.updateQuad(updatedQuad)
state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_200)
assertThat(state.editableQuad).isEqualTo(updatedQuad)
}
@Test
fun endDrag_clearsLastDragStepTracking() {
val state = EditPageScreenState()
state.updateQuad(testQuad)
state.startCornerDrag(0)
state.recordDragStep(testQuad, Offset(3f, 2f), eventTimeMs = 1_000)
state.updateQuad(updatedQuad)
state.endDrag()
state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_010)
assertThat(state.editableQuad).isEqualTo(updatedQuad)
}
// ── full interaction cycles ───────────────────────────────────────────────
@Test
fun tapCycle_leavesStateConsistent() {
val state = EditPageScreenState()
val pos = Offset(100f, 200f)
state.onTouchDown(pos, cornerIndex = 3)
assertThat(state.isTouching).isTrue()
assertThat(state.touchDownCornerIndex).isEqualTo(3)
state.onTouchUp()
assertThat(state.isTouching).isFalse()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
assertThat(state.dragPosition).isEqualTo(pos) // preserved for loupe fade-out
assertThat(state.isDragging()).isFalse()
}
@Test
fun dragCycle_corner_leavesStateConsistent() {
val state = EditPageScreenState()
state.setInitialQuad(testQuad)
val pos = Offset(100f, 200f)
state.onTouchDown(pos, cornerIndex = 1)
state.startCornerDrag(1)
assertThat(state.isDragging()).isTrue()
assertThat(state.isTouching).isTrue()
assertThat(state.draggedCornerIndex).isEqualTo(1)
assertThat(state.touchDownCornerIndex).isEqualTo(1)
state.updateQuad(updatedQuad)
state.endDrag()
state.onTouchUp()
assertThat(state.isDragging()).isFalse()
assertThat(state.isTouching).isFalse()
assertThat(state.draggedCornerIndex).isEqualTo(-1)
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
assertThat(state.dragPosition).isEqualTo(pos) // preserved for loupe fade-out
assertThat(state.editableQuad).isEqualTo(updatedQuad)
}
@Test
fun dragCycle_edge_leavesStateConsistent() {
// Edge dragging is no longer supported; this test verifies that a touch
// without a valid corner index simply does not trigger a drag.
val state = EditPageScreenState()
state.setInitialQuad(testQuad)
val pos = Offset(150f, 80f)
state.onTouchDown(pos)
assertThat(state.isDragging()).isFalse()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
state.onTouchUp()
assertThat(state.isDragging()).isFalse()
assertThat(state.isTouching).isFalse()
assertThat(state.touchDownCornerIndex).isEqualTo(-1)
assertThat(state.dragPosition).isEqualTo(pos)
}
@Test
fun consecutiveTaps_eachSetsCorrectTouchDownIndex() {
val state = EditPageScreenState()
state.onTouchDown(Offset(10f, 10f), cornerIndex = 0)
assertThat(state.touchDownCornerIndex).isEqualTo(0)
state.onTouchUp()
state.onTouchDown(Offset(90f, 10f), cornerIndex = 1)
assertThat(state.touchDownCornerIndex).isEqualTo(1)
state.onTouchUp()
state.onTouchDown(Offset(90f, 90f), cornerIndex = 2)
assertThat(state.touchDownCornerIndex).isEqualTo(2)
state.onTouchUp()
}
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
/** Acceptable offset for float comparisons **/
private val FLOAT_OFFSET = org.assertj.core.data.Offset.offset(0.0001f)
class MagnifyingGlassTest {
private val configPx = LoupeLayoutConfig(
loupeRadius = 150f,
verticalOffset = 200f,
screenMargin = 20f,
)
private val containerWidth = 1080f
@Test
fun abovePlacement_whenPlentyOfRoomAbove() {
// Finger in the middle of the screen, plenty of room everywhere
val drag = Offset(540f, 800f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// Expected Y: 800 - 200 - 150 = 450
assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET)
// X should stay centred on finger
assertThat(result.x).isCloseTo(540f, FLOAT_OFFSET)
}
@Test
fun abovePlacement_clampsXToLeftEdge() {
// Finger very close to the left edge
val drag = Offset(50f, 800f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// X should be clamped to screenMargin + loupeRadius = 20 + 150 = 170
assertThat(result.x).isCloseTo(170f, FLOAT_OFFSET)
// Still placed above
assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET)
}
@Test
fun abovePlacement_clampsXToRightEdge() {
// Finger very close to the right edge
val drag = Offset(1060f, 800f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// X should be clamped to containerWidth - screenMargin - loupeRadius = 1080 - 20 - 150 = 910
assertThat(result.x).isCloseTo(910f, FLOAT_OFFSET)
// Still placed above
assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET)
}
@Test
fun leftPlacement_whenNotEnoughRoomAbove() {
// Finger near the top: Y must be small enough that above placement fails
// above center Y = dragY - verticalOffset - loupeRadius
// condition: aboveCenterY - loupeRadius >= screenMargin
// => dragY - 200 - 150 - 150 >= 20 => dragY >= 520
// Use dragY = 300 (not enough room above)
// Finger at centre-X so there IS room to the left
val drag = Offset(540f, 300f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// Left center X = 540 - 200 - 150 = 190
// leftCenterX - loupeRadius = 190 - 150 = 40 >= 20 ✓
assertThat(result.x).isCloseTo(190f, FLOAT_OFFSET)
// Y should equal dragY (clamped, but 300 > screenMargin + loupeRadius = 170)
assertThat(result.y).isCloseTo(300f, FLOAT_OFFSET)
}
@Test
fun leftPlacement_clampsYToTopEdge() {
// Finger at very top and far right (no room above, room to left)
val drag = Offset(540f, 100f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// Left placement: X = 540 - 200 - 150 = 190 (room check: 190 - 150 = 40 >= 20 ✓)
assertThat(result.x).isCloseTo(190f, FLOAT_OFFSET)
// Y clamped to screenMargin + loupeRadius = 20 + 150 = 170
assertThat(result.y).isCloseTo(170f, FLOAT_OFFSET)
}
@Test
fun rightPlacement_whenNoRoomAboveOrLeft() {
// Finger near top-left corner: not enough room above AND not enough room on the left
// For left to fail: leftCenterX - loupeRadius < screenMargin
// leftCenterX = dragX - 200 - 150 = dragX - 350
// leftCenterX - 150 = dragX - 500 < 20 => dragX < 520
// Also not enough room above: dragY < 520
val drag = Offset(100f, 300f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// Right center X = 100 + 200 + 150 = 450
// Clamped: min(450, 1080 - 20 - 150) = min(450, 910) = 450
assertThat(result.x).isCloseTo(450f, FLOAT_OFFSET)
// Y should equal dragY (300 > 170)
assertThat(result.y).isCloseTo(300f, FLOAT_OFFSET)
}
@Test
fun rightPlacement_clampsXToRightEdge() {
// Use a narrow container to force right placement with X clamping
val narrowResult = computeLoupeCenter(
Offset(100f, 300f), configPx, 500f
)
// Right center X = 100 + 200 + 150 = 450
// Clamped: min(450, 500 - 20 - 150) = min(450, 330) = 330
assertThat(narrowResult.x).isCloseTo(330f, FLOAT_OFFSET)
assertThat(narrowResult.y).isCloseTo(300f, FLOAT_OFFSET)
}
@Test
fun rightPlacement_clampsYToTopEdge() {
// Finger at extreme top-left: Y very small, no room above/left -> right, Y clamped
val drag = Offset(50f, 50f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// above: 50 - 200 - 150 = -300, -300 - 150 = -450 < 20 ✗
// left: 50 - 200 - 150 = -300, -300 - 150 = -450 < 20 ✗
// right: 50 + 200 + 150 = 400
assertThat(result.x).isCloseTo(400f, FLOAT_OFFSET)
// Y clamped to screenMargin + loupeRadius = 170
assertThat(result.y).isCloseTo(170f, FLOAT_OFFSET)
}
@Test
fun abovePlacement_exactBoundary() {
// dragY such that aboveCenterY - loupeRadius == screenMargin exactly
// dragY - verticalOffset - loupeRadius - loupeRadius = screenMargin
// dragY = screenMargin + 2*loupeRadius + verticalOffset = 20 + 300 + 200 = 520
val drag = Offset(540f, 520f)
val result = computeLoupeCenter(drag, configPx, containerWidth)
// Should still place above (condition uses >=)
val expectedY = 520f - 200f - 150f // = 170
assertThat(result.y).isCloseTo(expectedY, FLOAT_OFFSET)
assertThat(result.x).isCloseTo(540f, FLOAT_OFFSET)
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.data.Offset as AssertJOffset
import org.fairscan.imageprocessing.Point
import org.junit.Test
class QuadCoordinateUtilsTest {
@Test
fun calculateDisplaySize_scalesCorrectlyForVariousAspectRatios() {
// Wider image than container - fits to width
var result = QuadCoordinateUtils.calculateDisplaySize(1920, 1080, IntSize(1000, 800))
assertThat(result.width).isEqualTo(1000)
assertThat(result.height).isCloseTo(562, AssertJOffset.offset(1))
// Taller image than container - fits to height
result = QuadCoordinateUtils.calculateDisplaySize(1080, 1920, IntSize(1000, 800))
assertThat(result.height).isEqualTo(800)
assertThat(result.width).isCloseTo(450, AssertJOffset.offset(1))
// Square image in square container
result = QuadCoordinateUtils.calculateDisplaySize(500, 500, IntSize(1000, 1000))
assertThat(result.width).isEqualTo(1000)
assertThat(result.height).isEqualTo(1000)
// Same aspect ratio - fills container
result = QuadCoordinateUtils.calculateDisplaySize(800, 600, IntSize(400, 300))
assertThat(result.width).isEqualTo(400)
assertThat(result.height).isEqualTo(300)
}
@Test
fun normalizedToScreen_convertsPointsWithCorrectOffset() {
val containerSize = IntSize(1000, 800)
val displaySize = IntSize(800, 600)
// Offset: (1000-800)/2 = 100 for x, (800-600)/2 = 100 for y
// Top-left corner (0,0)
var result = QuadCoordinateUtils.normalizedToScreen(Point(0.0, 0.0), containerSize, displaySize)
assertThat(result.x).isCloseTo(100f, AssertJOffset.offset(0.1f))
assertThat(result.y).isCloseTo(100f, AssertJOffset.offset(0.1f))
// Center (0.5, 0.5) -> x: 0.5*800+100=500, y: 0.5*600+100=400
result = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize)
assertThat(result.x).isCloseTo(500f, AssertJOffset.offset(0.1f))
assertThat(result.y).isCloseTo(400f, AssertJOffset.offset(0.1f))
// Bottom-right corner (1,1) -> x: 1.0*800+100=900, y: 1.0*600+100=700
result = QuadCoordinateUtils.normalizedToScreen(Point(1.0, 1.0), containerSize, displaySize)
assertThat(result.x).isCloseTo(900f, AssertJOffset.offset(0.1f))
assertThat(result.y).isCloseTo(700f, AssertJOffset.offset(0.1f))
// No offset when sizes match
result = QuadCoordinateUtils.normalizedToScreen(Point(0.0, 0.0), IntSize(800, 600), IntSize(800, 600))
assertThat(result.x).isCloseTo(0f, AssertJOffset.offset(0.1f))
assertThat(result.y).isCloseTo(0f, AssertJOffset.offset(0.1f))
}
@Test
fun screenDeltaToNormalized_convertsDeltas() {
val displaySize = IntSize(800, 600)
// Positive delta: 80/800=0.1, 60/600=0.1
var result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(80f, 60f), displaySize)
assertThat(result.x).isCloseTo(0.1f, AssertJOffset.offset(0.001f))
assertThat(result.y).isCloseTo(0.1f, AssertJOffset.offset(0.001f))
// Zero delta
result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(0f, 0f), displaySize)
assertThat(result.x).isEqualTo(0f)
assertThat(result.y).isEqualTo(0f)
// Negative delta: -160/800=-0.2, -120/600=-0.2
result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(-160f, -120f), displaySize)
assertThat(result.x).isCloseTo(-0.2f, AssertJOffset.offset(0.001f))
assertThat(result.y).isCloseTo(-0.2f, AssertJOffset.offset(0.001f))
}
@Test
fun getImageOffset_calculatesCorrectOffsets() {
// Standard offset
var result = QuadCoordinateUtils.getImageOffset(IntSize(1000, 800), IntSize(800, 600))
assertThat(result.width).isEqualTo(100)
assertThat(result.height).isEqualTo(100)
// Same size - zero offset
result = QuadCoordinateUtils.getImageOffset(IntSize(800, 600), IntSize(800, 600))
assertThat(result.width).isEqualTo(0)
assertThat(result.height).isEqualTo(0)
// Asymmetric offset (only horizontal)
result = QuadCoordinateUtils.getImageOffset(IntSize(1000, 600), IntSize(800, 600))
assertThat(result.width).isEqualTo(100)
assertThat(result.height).isEqualTo(0)
}
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright 2026 Philipp Hasper
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.data.Offset as AssertJOffset
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.junit.Before
import org.junit.Test
class QuadEditingHandlerTest {
private lateinit var handler: QuadEditingHandler
private val containerSize = IntSize(1000, 800)
private val displaySize = IntSize(800, 600)
private val centeredQuad = Quad(
topLeft = Point(0.2, 0.2),
topRight = Point(0.8, 0.2),
bottomRight = Point(0.8, 0.8),
bottomLeft = Point(0.2, 0.8)
)
@Before
fun setUp() {
handler = QuadEditingHandler()
}
private fun closeTopCornersQuadForTouchRadius(): Quad {
val radiusPx = QuadEditingHandler.CORNER_TOUCH_RADIUS
// Keep the two top corners comfortably inside each other's touch radius.
val separationPx = radiusPx * 0.8f
val normalizedHalfSeparation = (separationPx / displaySize.width) / 2.0
val centerX = 0.5
return Quad(
topLeft = Point(centerX - normalizedHalfSeparation, 0.2),
topRight = Point(centerX + normalizedHalfSeparation, 0.2),
bottomRight = Point(0.8, 0.8),
bottomLeft = Point(0.2, 0.8)
)
}
@Test
fun findTouchedCorner_detectsAllCornersAndMisses() {
// All four corners should be detected
val corners = listOf(centeredQuad.topLeft, centeredQuad.topRight, centeredQuad.bottomRight, centeredQuad.bottomLeft)
corners.forEachIndexed { index, corner ->
val touchPos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize)
assertThat(handler.findTouchedCorner(touchPos, centeredQuad, containerSize, displaySize)).isEqualTo(index)
}
// Near corner (within radius) should also be detected
val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(centeredQuad.topLeft, containerSize, displaySize)
val nearTouch = Offset(topLeftScreen.x + 20f, topLeftScreen.y + 15f)
assertThat(handler.findTouchedCorner(nearTouch, centeredQuad, containerSize, displaySize)).isEqualTo(0)
// Outside visual corner radius but inside expanded touch radius.
val outsideVisualButTouchable = Offset(topLeftScreen.x + 70f, topLeftScreen.y)
assertThat((outsideVisualButTouchable - topLeftScreen).getDistance())
.isGreaterThan(QuadEditingHandler.CORNER_RADIUS)
assertThat(handler.findTouchedCorner(outsideVisualButTouchable, centeredQuad, containerSize, displaySize))
.isEqualTo(0)
// Far from corners should return -1
val centerTouch = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize)
assertThat(handler.findTouchedCorner(centerTouch, centeredQuad, containerSize, displaySize)).isEqualTo(-1)
}
@Test
fun findTouchedCorner_selectsClosestCornerWhenMultipleAreInTouchRadius() {
// Two corners close together so their touch areas overlap.
val closeTopCornersQuad = closeTopCornersQuadForTouchRadius()
val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topLeft, containerSize, displaySize)
val topRightScreen = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topRight, containerSize, displaySize)
val towardCornerOffset = QuadEditingHandler.CORNER_TOUCH_RADIUS * 0.2f
// Touch a bit to the left of topRight — inside both radii but closer to topRight (index 1).
val touchCloserToTopRight = Offset(topRightScreen.x - towardCornerOffset, topRightScreen.y)
assertThat(handler.findTouchedCorner(touchCloserToTopRight, closeTopCornersQuad, containerSize, displaySize))
.isEqualTo(1)
// Touch a bit to the right of topLeft — inside both radii but closer to topLeft (index 0).
val touchCloserToTopLeft = Offset(topLeftScreen.x + towardCornerOffset, topLeftScreen.y)
assertThat(handler.findTouchedCorner(touchCloserToTopLeft, closeTopCornersQuad, containerSize, displaySize))
.isEqualTo(0)
// Exact midpoint — equal distance to both; either index 0 or 1 is acceptable.
val midpointTouch = Offset((topLeftScreen.x + topRightScreen.x) / 2f, topLeftScreen.y)
assertThat(handler.findTouchedCorner(midpointTouch, closeTopCornersQuad, containerSize, displaySize))
.isIn(0, 1)
}
@Test
fun findTouchedCornerCandidates_returnsAllCornersInRadiusSortedByDistance() {
// Single corner in range: only that corner returned.
val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(centeredQuad.topLeft, containerSize, displaySize)
val single = handler.findTouchedCornerCandidates(topLeftScreen, centeredQuad, containerSize, displaySize)
assertThat(single).containsExactly(0)
// No corner in range: empty list.
val farAway = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize)
assertThat(handler.findTouchedCornerCandidates(farAway, centeredQuad, containerSize, displaySize)).isEmpty()
// Use a quad whose top two corners are spaced relative to CORNER_TOUCH_RADIUS.
val closeTopCornersQuad = closeTopCornersQuadForTouchRadius()
val closeTL = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topLeft, containerSize, displaySize)
val closeTR = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topRight, containerSize, displaySize)
val towardCornerOffset = QuadEditingHandler.CORNER_TOUCH_RADIUS * 0.2f
// Touch at midpoint: both in range, order may vary — but both must be present.
val midpoint = Offset((closeTL.x + closeTR.x) / 2f, closeTL.y)
val bothCandidates = handler.findTouchedCornerCandidates(midpoint, closeTopCornersQuad, containerSize, displaySize)
assertThat(bothCandidates).containsExactlyInAnyOrder(0, 1)
// Touch closer to topRight (index 1): topRight must be first in the list.
val touchNearTR = Offset(closeTR.x - towardCornerOffset, closeTR.y)
val overlap = handler.findTouchedCornerCandidates(touchNearTR, closeTopCornersQuad, containerSize, displaySize)
assertThat(overlap.first()).isEqualTo(1) // topRight is closest
assertThat(overlap).contains(0) // topLeft also a candidate
// Touch closer to topLeft (index 0): topLeft must be first.
val touchNearTL = Offset(closeTL.x + towardCornerOffset, closeTL.y)
val overlapTL = handler.findTouchedCornerCandidates(touchNearTL, closeTopCornersQuad, containerSize, displaySize)
assertThat(overlapTL.first()).isEqualTo(0)
}
@Test
fun updateQuadCorner_movesCorrectCornerAndClampsTooBounds() {
// Move each corner and verify only that corner changes
val deltas = listOf(Offset(0.1f, 0.1f), Offset(-0.1f, 0.1f), Offset(-0.1f, -0.1f), Offset(0.1f, -0.1f))
val expectedPositions = listOf(Point(0.3, 0.3), Point(0.7, 0.3), Point(0.7, 0.7), Point(0.3, 0.7))
deltas.forEachIndexed { index, delta ->
val result = handler.updateQuadCorner(centeredQuad, index, delta)
val movedCorner = when (index) {
0 -> result.topLeft
1 -> result.topRight
2 -> result.bottomRight
else -> result.bottomLeft
}
assertThat(movedCorner.x).isCloseTo(expectedPositions[index].x, AssertJOffset.offset(0.001))
assertThat(movedCorner.y).isCloseTo(expectedPositions[index].y, AssertJOffset.offset(0.001))
}
// Invalid index returns unchanged quad
assertThat(handler.updateQuadCorner(centeredQuad, 5, Offset(0.1f, 0.1f))).isEqualTo(centeredQuad)
// Zero delta returns unchanged quad
assertThat(handler.updateQuadCorner(centeredQuad, 0, Offset(0f, 0f))).isEqualTo(centeredQuad)
// Clamping to min bounds (0)
var result = handler.updateQuadCorner(centeredQuad, 0, Offset(-0.5f, -0.5f))
assertThat(result.topLeft.x).isEqualTo(0.0)
assertThat(result.topLeft.y).isEqualTo(0.0)
// Clamping to max bounds (1)
result = handler.updateQuadCorner(centeredQuad, 2, Offset(0.5f, 0.5f))
assertThat(result.bottomRight.x).isEqualTo(1.0)
assertThat(result.bottomRight.y).isEqualTo(1.0)
}
// ── Convexity enforcement tests ──────────────────────────────────────
@Test
fun updateQuadCorner_rejectsConcaveResult() {
// Drag the topLeft corner past the diagonal to create a concave quad.
// Moving topLeft far to the right and down should make the quad concave.
val result = handler.updateQuadCorner(centeredQuad, 0, Offset(0.7f, 0.7f))
// The result should still be convex, meaning the move was rejected
// (the original quad is returned).
assertThat(result).isEqualTo(centeredQuad)
}
@Test
fun updateQuadCorner_allowsConvexResult() {
// A small move that keeps the quad convex should be allowed.
val result = handler.updateQuadCorner(centeredQuad, 0, Offset(0.05f, 0.05f))
// The corner should have moved.
assertThat(result.topLeft.x).isCloseTo(0.25, AssertJOffset.offset(0.001))
assertThat(result.topLeft.y).isCloseTo(0.25, AssertJOffset.offset(0.001))
}
@Test
fun updateQuadCorner_allowsFixingConcaveQuad() {
// Start with a concave quad (topLeft is pushed too far inward).
val concaveQuad = Quad(
topLeft = Point(0.7, 0.7), // past the center, making it concave
topRight = Point(0.8, 0.2),
bottomRight = Point(0.8, 0.8),
bottomLeft = Point(0.2, 0.8)
)
// Move topLeft back outward to restore convexity.
val result = handler.updateQuadCorner(concaveQuad, 0, Offset(-0.5f, -0.5f))
// The move should be allowed because the result is convex.
assertThat(result.topLeft.x).isCloseTo(0.2, AssertJOffset.offset(0.001))
assertThat(result.topLeft.y).isCloseTo(0.2, AssertJOffset.offset(0.001))
}
@Test
fun updateQuadCorner_rejectsMoveAlongEdgeThatCreatesConcavity() {
// Start with a nearly-flat quad that's still convex.
val narrowQuad = Quad(
topLeft = Point(0.2, 0.2),
topRight = Point(0.8, 0.2),
bottomRight = Point(0.8, 0.3),
bottomLeft = Point(0.2, 0.3)
)
// Drag bottomLeft upward past the top edge — should be rejected.
val result = handler.updateQuadCorner(narrowQuad, 3, Offset(0.0f, -0.2f))
assertThat(result).isEqualTo(narrowQuad)
}
}

View File

@@ -47,6 +47,26 @@ data class Quad(
Line(bottomLeft, topLeft))
}
/**
* Returns `true` when the four corners form a strictly convex polygon with
* correct winding order (topLeft -> topRight -> bottomRight -> bottomLeft
* clockwise in screen coordinates where y increases downward).
*
* The check computes the cross product at each corner of consecutive edges
* and verifies that all four cross products are strictly positive.
*/
fun isConvex(): Boolean {
val pts = listOf(topLeft, topRight, bottomRight, bottomLeft)
for (i in pts.indices) {
val a = pts[i]
val b = pts[(i + 1) % 4]
val c = pts[(i + 2) % 4]
val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x)
if (cross <= 0.0) return false
}
return true
}
fun rotate90(iterations: Int, imageSize: ImageSize): Quad {
val rotatedPoints = listOf(
rotate90(topLeft, imageSize, iterations),