Add dark outlines to overlay guides and improve gesture zones

- Draw all guide lines with a dark outline behind the accent color
  to ensure visibility over bright/amber camera scenes
- Clamp gesture zone focus size to MIN_FOCUS_SIZE_PX (150px) so
  rotation zone remains usable at small focus sizes
- Add semantics contentDescription to Canvas for TalkBack
- Use AppColors for centralized color references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 12:07:50 +01:00
commit 72156f4e5d

View file

@ -22,9 +22,12 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
@ -39,13 +42,16 @@ private enum class GestureType {
DRAG_POSITION, // Single finger drag to move focus center DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch in center to zoom camera PINCH_ZOOM // Pinch far outside to zoom camera
} }
// Sensitivity factor for size pinch (lower = less sensitive) // Sensitivity factor for size pinch (lower = less sensitive)
private const val SIZE_SENSITIVITY = 0.3f private const val SIZE_SENSITIVITY = 0.3f
private const val ZOOM_SENSITIVITY = 0.5f private const val ZOOM_SENSITIVITY = 0.5f
/** Minimum focus size (in px) for gesture zones to ensure usable touch targets. */
private const val MIN_FOCUS_SIZE_PX = 150f
/** /**
* Calculates the angle between two touch points. * Calculates the angle between two touch points.
*/ */
@ -70,9 +76,16 @@ fun TiltShiftOverlay(
var currentGesture by remember { mutableStateOf(GestureType.NONE) } var currentGesture by remember { mutableStateOf(GestureType.NONE) }
val modeLabel = if (params.mode == BlurMode.LINEAR) "linear" else "radial"
Canvas( Canvas(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.semantics {
contentDescription = "Tilt-shift overlay: $modeLabel mode. " +
"Drag to move focus. Pinch near edges to resize. " +
"Pinch near center to rotate. Use sliders in controls panel for alternative adjustment."
}
.pointerInput(Unit) { .pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false) val firstDown = awaitFirstDown(requireUnconsumed = false)
@ -187,6 +200,8 @@ fun TiltShiftOverlay(
* - Very center (< 30% of focus size): Rotation * - Very center (< 30% of focus size): Rotation
* - Near focus region (30% - 200% of focus size): Size adjustment * - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom * - Far outside (> 200%): Camera zoom
*
* Focus size is clamped to a minimum pixel value to keep zones usable at small sizes.
*/ */
private fun determineGestureType( private fun determineGestureType(
centroid: Offset, centroid: Offset,
@ -196,7 +211,8 @@ private fun determineGestureType(
): GestureType { ): GestureType {
val focusCenterX = width * params.positionX val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY val focusCenterY = height * params.positionY
val focusSize = height * params.size * 0.5f // Clamp focus size to minimum to keep rotation zone reachable
val focusSize = maxOf(height * params.size * 0.5f, MIN_FOCUS_SIZE_PX)
val dx = centroid.x - focusCenterX val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY val dy = centroid.y - focusCenterY
@ -236,6 +252,7 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
/** /**
* Draws the linear mode overlay (horizontal band with rotation). * Draws the linear mode overlay (horizontal band with rotation).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/ */
private fun DrawScope.drawLinearOverlay(params: BlurParameters) { private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val width = size.width val width = size.width
@ -246,15 +263,20 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val focusHalfHeight = height * params.size * 0.5f val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay val focusLineColor = AppColors.Accent
val focusLineColor = Color(0xFFFFB300) // Amber val outlineColor = AppColors.OverlayOutline
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white val blurZoneColor = AppColors.OverlayLinearBlur
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated) // Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = sqrt(width * width + height * height) val diagonal = sqrt(width * width + height * height)
val extendedHalf = diagonal val extendedHalf = diagonal
val outlineWidth = 4.dp.toPx()
val lineWidth = 2.dp.toPx()
val centerLineWidth = 3.dp.toPx()
val centerOutlineWidth = 5.dp.toPx()
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom) // Draw blur zone indicators (top and bottom)
drawRect( drawRect(
@ -268,32 +290,60 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
size = Size(extendedHalf * 2, extendedHalf) size = Size(extendedHalf * 2, extendedHalf)
) )
// Draw focus zone boundary lines // Draw focus zone boundary lines (outline first, then color)
// Top boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight), end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = 2.dp.toPx(), strokeWidth = lineWidth,
pathEffect = dashEffect
)
// Bottom boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect pathEffect = dashEffect
) )
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight), start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight), end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = 2.dp.toPx(), strokeWidth = lineWidth,
pathEffect = dashEffect pathEffect = dashEffect
) )
// Draw center focus line // Draw center focus line (outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerOutlineWidth
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY), start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY), end = Offset(centerX + extendedHalf, centerY),
strokeWidth = 3.dp.toPx() strokeWidth = centerLineWidth
) )
// Draw rotation indicator at center // Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx() val indicatorRadius = 30.dp.toPx()
drawCircle(
color = outlineColor,
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 4.dp.toPx())
)
drawCircle( drawCircle(
color = focusLineColor.copy(alpha = 0.5f), color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius, radius = indicatorRadius,
@ -303,6 +353,12 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
// Draw angle tick mark // Draw angle tick mark
val tickLength = 15.dp.toPx() val tickLength = 15.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength), start = Offset(centerX, centerY - indicatorRadius + tickLength),
@ -314,6 +370,7 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
/** /**
* Draws the radial mode overlay (ellipse/circle). * Draws the radial mode overlay (ellipse/circle).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/ */
private fun DrawScope.drawRadialOverlay(params: BlurParameters) { private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val width = size.width val width = size.width
@ -324,9 +381,8 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val focusRadius = height * params.size * 0.5f val focusRadius = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay val focusLineColor = AppColors.Accent
val focusLineColor = Color(0xFFFFB300) // Amber val outlineColor = AppColors.OverlayOutline
val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
// Calculate ellipse dimensions based on aspect ratio // Calculate ellipse dimensions based on aspect ratio
@ -334,7 +390,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val ellipseHeight = focusRadius * 2 val ellipseHeight = focusRadius * 2
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw focus ellipse outline (inner boundary) // Draw focus ellipse outline (outline + color)
drawOval(
color = outlineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 5.dp.toPx())
)
drawOval( drawOval(
color = focusLineColor, color = focusLineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2), topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
@ -344,6 +406,15 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
// Draw outer blur boundary (with falloff) // Draw outer blur boundary (with falloff)
val outerScale = 1f + params.falloff val outerScale = 1f + params.falloff
drawOval(
color = outlineColor,
topLeft = Offset(
centerX - (ellipseWidth * outerScale) / 2,
centerY - (ellipseHeight * outerScale) / 2
),
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
style = Stroke(width = 4.dp.toPx(), pathEffect = dashEffect)
)
drawOval( drawOval(
color = focusLineColor.copy(alpha = 0.5f), color = focusLineColor.copy(alpha = 0.5f),
topLeft = Offset( topLeft = Offset(
@ -354,14 +425,26 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect) style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
) )
// Draw center crosshair // Draw center crosshair (outline + color)
val crosshairSize = 20.dp.toPx() val crosshairSize = 20.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 4.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - crosshairSize, centerY), start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY), end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 4.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - crosshairSize), start = Offset(centerX, centerY - crosshairSize),
@ -369,7 +452,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
// Draw rotation indicator (small line at top of ellipse) // Draw rotation indicator (small line at top of ellipse, outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()), start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),