EditPage: New screen for editing an individual page
This commit is contained in:
committed by
Pierre-Yves Nicolas
parent
dcc797785b
commit
2b63273168
@@ -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) },
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user