Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ec074766 authored by Wes Okuhara's avatar Wes Okuhara Committed by Android (Google) Code Review
Browse files

Merge "Screen capture: Region box tap target sizes for mouse vs touch" into main

parents d0075dd9 009b3c9c
Loading
Loading
Loading
Loading
+104 −86
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -44,11 +45,11 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.SubcomposeLayout
@@ -57,6 +58,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -64,6 +66,7 @@ import androidx.compose.ui.unit.dp
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.screencapture.common.ui.compose.PrimaryButton
import com.android.systemui.screencapture.common.ui.compose.ScreenCaptureColors
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@@ -117,22 +120,16 @@ private fun isRectInScreen(rect: Rect, screenWidth: Float, screenHeight: Float):
 * @param tapTargetSizePx The size of an individual tap target in pixels.
 * @return The `ResizeZone` that is tapped or hovered, or `null` if none.
 */
private fun getTappedOrHoveredZone(
private fun getResizeZone(
    boxWidth: Float,
    boxHeight: Float,
    pointerOffset: Offset,
    tapTargetSizePx: Float,
): ResizeZone? {
    val tapTargetHalfPx = tapTargetSizePx / 2
    val tapTargetHalfPx = floor(tapTargetSizePx / 2)

    // Check if the press is within the overall zone of the box.
    val boxZone =
        Rect(
            left = -tapTargetHalfPx,
            top = -tapTargetHalfPx,
            right = boxWidth + tapTargetHalfPx,
            bottom = boxHeight + tapTargetHalfPx,
        )
    val boxZone = Rect(0f, 0f, boxWidth, boxHeight).inflate(tapTargetHalfPx)
    if (!boxZone.contains(pointerOffset)) {
        return null
    }
@@ -165,16 +162,16 @@ private fun getTappedOrHoveredZone(
 * A class that encapsulates the state and logic for the RegionBox composable.
 *
 * @param minSizePx The minimum size of the box in pixels.
 * @param touchAreaPx The size of the touch area for resizing in pixels.
 * @param density The density of the screen. Used for the conversions between pixels and Dp.
 */
class RegionBoxState(private val minSizePx: Float, private val touchAreaPx: Float) {
class RegionBoxState(private val minSizePx: Float, private val density: Density) {
    var rect by mutableStateOf<Rect?>(null)

    var dragMode by mutableStateOf(DragMode.NONE)

    /**
     * Tracks which edge or corner of the selection box the user has clicked on to start a
     * drag-to-resize action (i.e., dragMode == DragMode.RESIZING).
     * Tracks which edge or corner of the selection box the user is currently dragging to resize the
     * box.
     */
    var resizeZone by mutableStateOf<ResizeZone?>(null)

@@ -193,46 +190,25 @@ class RegionBoxState(private val minSizePx: Float, private val touchAreaPx: Floa
    /** True if the user is currently hovering over the capture button. */
    var isHoveringButton by mutableStateOf(false)

    // The offset of the initial press when the user starts a drag gesture.
    /**
     * The offset of the initial press when the user starts a drag gesture. The offset is relative
     * to the overall screen bounds.
     */
    var newBoxStartOffset by mutableStateOf(Offset.Zero)

    // Must remember the screen size for the drag logic. Initial values are set to 0.
    var screenWidth by mutableStateOf(0f)
    var screenHeight by mutableStateOf(0f)

    fun startDrag(startOffset: Offset) {
        val currentRect = rect
    var screenWidth by mutableFloatStateOf(0f)
    var screenHeight by mutableFloatStateOf(0f)

        if (currentRect == null) {
            // If the box is not yet created, it is a drawing drag.
            dragMode = DragMode.DRAWING
            newBoxStartOffset = startOffset
        } else {
            val tappedZone =
                getTappedOrHoveredZone(
                    boxWidth = currentRect.width,
                    boxHeight = currentRect.height,
                    pointerOffset = startOffset - currentRect.topLeft,
                    tapTargetSizePx = touchAreaPx,
                )
            when {
                tappedZone != null -> {
                    // If the drag was initiated within the current rectangle's drag-to-resize touch
                    // zone, it is a resizing drag.
                    dragMode = DragMode.RESIZING
                    resizeZone = tappedZone
                }
                currentRect.contains(startOffset) -> {
                    // If the drag was initiated inside the rectangle and not within the touch
                    // zones, it is a moving drag.
                    dragMode = DragMode.MOVING
                }
                else -> {
                    // The touch was initiated outside of the rectangle and its touch zone.
                    dragMode = DragMode.DRAWING
                    newBoxStartOffset = startOffset
                }
            }
    /**
     * Determines which drag mode is being initiated based on the given pointer type and position.
     */
    fun startDrag(pointerType: PointerType, pointerPosition: Offset) {
        val (newDragMode, newResizeZone) = getDragModeForPointer(pointerType, pointerPosition)
        dragMode = newDragMode
        resizeZone = newResizeZone
        if (newDragMode == DragMode.DRAWING) {
            newBoxStartOffset = pointerPosition
        }
    }

@@ -312,37 +288,67 @@ class RegionBoxState(private val minSizePx: Float, private val touchAreaPx: Floa
    }

    /**
     * A low-level pointer input handler that is used to update the hover state of the different
     * parts of the UI.
     * Determines which part of the region box is being hovered based on the given `pointerType` and
     * the `pointerPosition` relative to the box bounds and tap targets.
     */
    fun updateHoverState(event: PointerEvent) {
        val change = event.changes.first()
        val pointerPosition = change.position
        val isPressed = change.pressed
    fun updateHoverState(pointerType: PointerType, pointerPosition: Offset) {
        // If there is no box, then there is nothing to hover.
        val currentRect = rect ?: return

        // Don't update hover state if a button is pressed to prevent flicker
        // during drags.
        if (isPressed) {
            return
        hoveredZone = getResizeZone(pointerType, pointerPosition)
        isHoveringBox = currentRect.contains(pointerPosition)
        captureButtonBounds?.let { buttonBounds ->
            val globalButtonBounds = buttonBounds.translate(currentRect.topLeft)
            isHoveringButton = globalButtonBounds.contains(pointerPosition)
        }
    }

        if (event.type == PointerEventType.Move) {
            rect?.let { currentRect ->
                hoveredZone =
                    getTappedOrHoveredZone(
    private fun getDragModeForPointer(
        pointerType: PointerType,
        pointerPosition: Offset,
    ): Pair<DragMode, ResizeZone?> {
        // If the box is not yet created, it is a drawing drag.
        val currentRect = rect ?: return Pair(DragMode.DRAWING, null)

        val currentResizeZone = getResizeZone(pointerType, pointerPosition)
        return when {
            // If the drag is initiated within the box's resize zones, it is a resizing drag.
            currentResizeZone != null -> Pair(DragMode.RESIZING, currentResizeZone)
            // If the drag was initiated outside the touch zones but inside the box, it is a moving
            // drag.
            currentRect.contains(pointerPosition) -> Pair(DragMode.MOVING, null)
            // The drag is initiated outside the box and resize zones so it is a drawing drag.
            else -> Pair(DragMode.DRAWING, null)
        }
    }

    private fun getResizeZone(pointerType: PointerType, pointerPosition: Offset): ResizeZone? {
        val currentRect = rect ?: return null

        val pointerOffset = pointerPosition - currentRect.topLeft
        val tapTargetSizePx = getTapTargetSize(pointerType)

        return getResizeZone(
            boxWidth = currentRect.width,
            boxHeight = currentRect.height,
                        pointerOffset = pointerPosition - currentRect.topLeft,
                        tapTargetSizePx = touchAreaPx,
            pointerOffset = pointerOffset,
            tapTargetSizePx = tapTargetSizePx,
        )

                isHoveringBox = currentRect.contains(pointerPosition)

                captureButtonBounds?.let { buttonBounds ->
                    val globalButtonBounds = buttonBounds.translate(currentRect.topLeft)
                    isHoveringButton = globalButtonBounds.contains(pointerPosition)
    }

    private fun getTapTargetSize(pointerType: PointerType): Float {
        return with(density) { if (isPreciseTool(pointerType)) 36.dp.toPx() else 48.dp.toPx() }
    }

    private fun isPreciseTool(pointerType: PointerType): Boolean {
        return when (pointerType) {
            // Mouse, stylus, and touchpad are more accurate tools
            PointerType.Mouse,
            PointerType.Stylus -> true
            // Touchscreen and other types are not
            PointerType.Touch,
            PointerType.Unknown -> false
            else -> false
        }
    }
}
@@ -375,11 +381,7 @@ fun RegionBox(
    val minSize = 48.dp
    val minSizePx = remember(density) { with(density) { minSize.toPx() } }

    // The touch area for detecting an edge or corner resize drag.
    val touchArea = 48.dp
    val touchAreaPx = remember(density) { with(density) { touchArea.toPx() } }

    val state = remember { RegionBoxState(minSizePx, touchAreaPx) }
    val state = remember { RegionBoxState(minSizePx, density) }
    val scrimColor = ScreenCaptureColors.scrimColor
    val pointerIcon = rememberPointerIcon(state)

@@ -397,16 +399,32 @@ fun RegionBox(
                .pointerInput(Unit) {
                    awaitPointerEventScope {
                        while (true) {
                            state.updateHoverState(awaitPointerEvent(PointerEventPass.Main))
                            val pointerEvent = awaitPointerEvent(PointerEventPass.Main)
                            // Do not update hover state if the pointer was not moved.
                            if (pointerEvent.type != PointerEventType.Move) {
                                continue
                            }

                            val pointerChange = pointerEvent.changes.first()
                            // Don't update hover state if the pointer is pressed to prevent flicker
                            // during drags.
                            if (pointerChange.pressed) {
                                continue
                            }

                            state.updateHoverState(pointerChange.type, pointerChange.position)
                        }
                    }
                }
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { startOffset: Offset -> state.startDrag(startOffset) },
                        onDrag = { change: PointerInputChange, dragAmount: Offset ->
                            change.consume()
                            state.drag(change.position, dragAmount)
                        orientationLock = null,
                        onDragStart = { pointerChange: PointerInputChange, _, _ ->
                            state.startDrag(pointerChange.type, pointerChange.position)
                        },
                        onDrag = { pointerChange: PointerInputChange, dragAmount: Offset ->
                            pointerChange.consume()
                            state.drag(pointerChange.position, dragAmount)
                        },
                        onDragEnd = {
                            state.dragEnd()
+51 −34
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ package com.android.systemui.screencapture.record.largescreen.ui.compose

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.unit.Density
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
@@ -32,11 +34,12 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RegionBoxStateTest : SysuiTestCase() {
    private lateinit var state: RegionBoxState
    private var touchOffsetPx = TOUCH_TARGET_SIZE_PX / 4f

    @Before
    fun setUp() {
        // Initialize the state before each test
        state = RegionBoxState(MIN_SIZE_PX, TOUCH_AREA_PX)
        state = RegionBoxState(MIN_SIZE_PX, Density(DENSITY))
        state.screenWidth = SCREEN_WIDTH
        state.screenHeight = SCREEN_HEIGHT
    }
@@ -47,35 +50,35 @@ class RegionBoxStateTest : SysuiTestCase() {
    }

    @Test
    fun startDrag_withNullRect_setsDrawingMode() {
        val startOffset = Offset(100f, 150f)
        state.startDrag(startOffset)
    fun startDrag_withNoRect_setsDrawingMode() {
        val pointerPosition = Offset(100f, 150f)
        state.startDrag(PointerType.Mouse, pointerPosition)

        assertThat(state.dragMode).isEqualTo(DragMode.DRAWING)
        assertThat(state.rect).isNull()
        assertThat(state.newBoxStartOffset).isEqualTo(startOffset)
        assertThat(state.newBoxStartOffset).isEqualTo(pointerPosition)
    }

    @Test
    fun startDrag_outsideExistingRect_setsDrawingMode() {
    fun startDrag_outsideRect_setsDrawingMode() {
        state.rect = Rect(100f, 100f, 200f, 200f)

        val startOffset = Offset(500f, 500f)
        val pointerPosition = Offset(500f, 500f)

        // Start drag far outside the existing rect and its touch zones
        state.startDrag(startOffset)
        state.startDrag(PointerType.Mouse, pointerPosition)

        assertThat(state.dragMode).isEqualTo(DragMode.DRAWING)
        assertThat(state.resizeZone).isNull()
        assertThat(state.newBoxStartOffset).isEqualTo(startOffset)
        assertThat(state.newBoxStartOffset).isEqualTo(pointerPosition)
    }

    @Test
    fun startDrag_insideExistingRect_setsMovingMode() {
    fun startDrag_insideRect_setsMovingMode() {
        val currentRect = Rect(100f, 100f, 300f, 300f)
        // Start drag inside the existing rect, away from edges
        state.rect = currentRect
        state.startDrag(currentRect.center)
        state.startDrag(PointerType.Mouse, currentRect.center)

        assertThat(state.dragMode).isEqualTo(DragMode.MOVING)
        assertThat(state.resizeZone).isNull()
@@ -91,7 +94,7 @@ class RegionBoxStateTest : SysuiTestCase() {

        // Start drag on the specified point (corner or edge)
        val dragStartPoint = getDragPoint(currentRect)
        state.startDrag(dragStartPoint)
        state.startDrag(PointerType.Mouse, dragStartPoint)

        assertThat(state.dragMode).isEqualTo(DragMode.RESIZING)
        assertThat(state.resizeZone).isEqualTo(expectedZone)
@@ -100,9 +103,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onTopLeftCorner_setsResizingModeToTopLeft() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect ->
                rect.topLeft + Offset(TOUCH_AREA_PX / 4f, TOUCH_AREA_PX / 4f)
            },
            getDragPoint = { rect -> rect.topLeft + Offset(touchOffsetPx, touchOffsetPx) },
            expectedZone = ResizeZone.Corner.TopLeft,
        )
    }
@@ -110,9 +111,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onTopRightCorner_setsResizingModeToTopRight() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect ->
                rect.topRight + Offset(-TOUCH_AREA_PX / 4f, TOUCH_AREA_PX / 4f)
            },
            getDragPoint = { rect -> rect.topRight + Offset(-touchOffsetPx, touchOffsetPx) },
            expectedZone = ResizeZone.Corner.TopRight,
        )
    }
@@ -120,9 +119,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onBottomLeftCorner_setsResizingModeToBottomLeft() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect ->
                rect.bottomLeft + Offset(TOUCH_AREA_PX / 4f, -TOUCH_AREA_PX / 4f)
            },
            getDragPoint = { rect -> rect.bottomLeft + Offset(touchOffsetPx, -touchOffsetPx) },
            expectedZone = ResizeZone.Corner.BottomLeft,
        )
    }
@@ -130,9 +127,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onBottomRightCorner_setsResizingModeToBottomRight() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect ->
                rect.bottomRight + Offset(-TOUCH_AREA_PX / 4f, -TOUCH_AREA_PX / 4f)
            },
            getDragPoint = { rect -> rect.bottomRight + Offset(-touchOffsetPx, -touchOffsetPx) },
            expectedZone = ResizeZone.Corner.BottomRight,
        )
    }
@@ -140,7 +135,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onTopEdge_setsResizingModeToTop() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect -> Offset(rect.center.x, rect.top + TOUCH_AREA_PX / 4f) },
            getDragPoint = { rect -> Offset(rect.center.x, rect.top + touchOffsetPx) },
            expectedZone = ResizeZone.Edge.Top,
        )
    }
@@ -148,7 +143,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onBottomEdge_setsResizingModeToBottom() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect -> Offset(rect.center.x, rect.bottom - TOUCH_AREA_PX / 4f) },
            getDragPoint = { rect -> Offset(rect.center.x, rect.bottom - touchOffsetPx) },
            expectedZone = ResizeZone.Edge.Bottom,
        )
    }
@@ -156,7 +151,7 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onLeftEdge_setsResizingModeToLeft() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect -> Offset(rect.left + TOUCH_AREA_PX / 4f, rect.center.y) },
            getDragPoint = { rect -> Offset(rect.left + touchOffsetPx, rect.center.y) },
            expectedZone = ResizeZone.Edge.Left,
        )
    }
@@ -164,14 +159,35 @@ class RegionBoxStateTest : SysuiTestCase() {
    @Test
    fun startDrag_onRightEdge_setsResizingModeToRight() {
        handleAndAssertStartDragResizes(
            getDragPoint = { rect -> Offset(rect.right - TOUCH_AREA_PX / 4f, rect.center.y) },
            getDragPoint = { rect -> Offset(rect.right - touchOffsetPx, rect.center.y) },
            expectedZone = ResizeZone.Edge.Right,
        )
    }

    @Test
    fun startDrag_withMousePointerType_hasSmallerTargetSize() {
        val currentRect = Rect(100f, 100f, 300f, 300f)
        state.rect = currentRect

        val pointerPosition =
            currentRect.topLeft + Offset(TOUCH_TARGET_SIZE_PX / 2f, TOUCH_TARGET_SIZE_PX / 2f)

        // Demonstrate that touch type for the position is treated as resizing.
        state.startDrag(PointerType.Touch, pointerPosition)

        assertThat(state.dragMode).isEqualTo(DragMode.RESIZING)
        assertThat(state.resizeZone).isEqualTo(ResizeZone.Corner.TopLeft)

        // Demonstrate that touch type for same position is not treated as resizing.
        state.startDrag(PointerType.Mouse, pointerPosition)

        assertThat(state.dragMode).isEqualTo(DragMode.MOVING)
        assertThat(state.resizeZone).isNull()
    }

    @Test
    fun drag_inDrawingMode_createsCorrectRect() {
        state.startDrag(Offset(100f, 100f))
        state.startDrag(PointerType.Mouse, Offset(100f, 100f))
        val endOffset = Offset(200f, 250f)
        assertThat(state.dragMode).isEqualTo(DragMode.DRAWING)
        state.drag(endOffset, Offset.Zero)
@@ -188,7 +204,7 @@ class RegionBoxStateTest : SysuiTestCase() {

    @Test
    fun drag_inDrawingMode_constrainsToScreenBounds() {
        state.startDrag(Offset(50f, 50f))
        state.startDrag(PointerType.Mouse, Offset(50f, 50f))

        // Drag outside screen boundaries
        val endOffset = Offset(SCREEN_WIDTH + 100f, SCREEN_HEIGHT + 100f)
@@ -205,7 +221,7 @@ class RegionBoxStateTest : SysuiTestCase() {
        state.rect = initialRect
        val dragStartPoint = initialRect.center

        state.startDrag(dragStartPoint)
        state.startDrag(PointerType.Mouse, dragStartPoint)
        assertThat(state.dragMode).isEqualTo(DragMode.MOVING)

        val dragAmount = Offset(50f, 70f)
@@ -225,7 +241,7 @@ class RegionBoxStateTest : SysuiTestCase() {
        state.rect = initialRect

        val dragStartPoint = initialRect.center
        state.startDrag(dragStartPoint)
        state.startDrag(PointerType.Mouse, dragStartPoint)
        assertThat(state.dragMode).isEqualTo(DragMode.MOVING)

        val currentDragPosition = dragStartPoint + dragAmount
@@ -380,7 +396,7 @@ class RegionBoxStateTest : SysuiTestCase() {
            }

        val screenDragStartOffset = initialRect.topLeft + dragStartOffsetInBox
        state.startDrag(screenDragStartOffset)
        state.startDrag(PointerType.Mouse, screenDragStartOffset)
        assertThat(state.dragMode).isEqualTo(DragMode.RESIZING)
        assertThat(state.resizeZone).isEqualTo(resizeZone)

@@ -1202,8 +1218,9 @@ class RegionBoxStateTest : SysuiTestCase() {
    }

    companion object {
        private const val DENSITY = 1f
        private const val MIN_SIZE_PX = 50f
        private const val TOUCH_AREA_PX = 20f
        private const val TOUCH_TARGET_SIZE_PX = 48f

        private const val SCREEN_WIDTH = 800f
        private const val SCREEN_HEIGHT = 600f