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

Commit 233d6092 authored by Olivier St-Onge's avatar Olivier St-Onge Committed by Android (Google) Code Review
Browse files

Merge "Limit edit functions based on selected tab" into main

parents ddcbed83 3e6b08e6
Loading
Loading
Loading
Loading
+81 −47
Original line number Diff line number Diff line
@@ -336,7 +336,13 @@ fun DefaultEditTileGrid(
                    onEditAction = onEditAction,
                    editModeTabViewModel = editModeTabViewModel,
                ) {
                    CurrentTilesGrid(listState, selectionState, onEditAction)
                    CurrentTilesGrid(
                        listState = listState,
                        selectionState = selectionState,
                        canRemoveTiles = editModeTabViewModel.selectedTab.isTilesEditingAllowed,
                        canLayoutTiles = editModeTabViewModel.selectedTab.isTilesLayoutAllowed,
                        onEditAction = onEditAction,
                    )

                    AnimatedAvailableTilesGrid(
                        allTiles = allTiles,
@@ -360,7 +366,13 @@ fun DefaultEditTileGrid(
                        modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
                    )

                    CurrentTilesGrid(listState, selectionState, onEditAction)
                    CurrentTilesGrid(
                        listState = listState,
                        selectionState = selectionState,
                        canRemoveTiles = true,
                        canLayoutTiles = true,
                        onEditAction = onEditAction,
                    )

                    // Only show available tiles when a drag or placement isn't in progress, OR the
                    // drag is within the current tiles grid
@@ -604,6 +616,8 @@ private fun EditGridCenteredText(text: String, modifier: Modifier = Modifier) {
private fun CurrentTilesGrid(
    listState: EditTileListState,
    selectionState: MutableSelectionState,
    canRemoveTiles: Boolean,
    canLayoutTiles: Boolean,
    onEditAction: (EditAction) -> Unit,
) {
    val currentListState by rememberUpdatedState(listState)
@@ -650,6 +664,8 @@ private fun CurrentTilesGrid(
            listState = listState,
            selectionState = selectionState,
            gridState = gridState,
            canRemoveTiles = canRemoveTiles,
            canLayoutTiles = canLayoutTiles,
            coroutineScope = coroutineScope,
            onRemoveTile = { onEditAction(EditAction.RemoveTile(it)) },
        ) { resizingOperation ->
@@ -825,6 +841,8 @@ private fun GridCell.key(index: Int): Any {
 * @param listState the [EditTileListState] for this grid
 * @param selectionState the [MutableSelectionState] for this grid
 * @param gridState the [LazyGridState] for this grid
 * @param canRemoveTiles whether tiles can be removed from this grid
 * @param canLayoutTiles whether tiles can be reordered/resized
 * @param coroutineScope the [CoroutineScope] to be used for the tiles
 * @param onRemoveTile the callback when a tile is removed from this grid
 * @param onResize the callback when a tile has a new [ResizeOperation]
@@ -833,6 +851,8 @@ fun LazyGridScope.EditTiles(
    listState: EditTileListState,
    selectionState: MutableSelectionState,
    gridState: LazyGridState,
    canRemoveTiles: Boolean,
    canLayoutTiles: Boolean,
    coroutineScope: CoroutineScope,
    onRemoveTile: (TileSpec) -> Unit,
    onResize: (operation: ResizeOperation) -> Unit,
@@ -863,6 +883,8 @@ fun LazyGridScope.EditTiles(
                        dragAndDropState = listState,
                        selectionState = selectionState,
                        gridState = gridState,
                        canRemoveTile = canRemoveTiles,
                        canLayoutTile = canLayoutTiles,
                        onResize = onResize,
                        onRemoveTile = onRemoveTile,
                        coroutineScope = coroutineScope,
@@ -883,9 +905,10 @@ fun LazyGridScope.EditTiles(
private fun rememberTileState(
    tile: EditTileViewModel,
    selectionState: MutableSelectionState,
    canRemoveTile: Boolean,
): State<TileState> {
    val tileState = remember { mutableStateOf(TileState.None) }
    val canShowRemovalBadge = tile.isRemovable
    val canShowRemovalBadge = canRemoveTile && tile.isRemovable

    LaunchedEffect(selectionState.selection, selectionState.placementEnabled, canShowRemovalBadge) {
        tileState.value =
@@ -902,6 +925,8 @@ private fun LazyGridItemScope.TileGridCell(
    dragAndDropState: DragAndDropState,
    selectionState: MutableSelectionState,
    gridState: LazyGridState,
    canRemoveTile: Boolean,
    canLayoutTile: Boolean,
    onResize: (operation: ResizeOperation) -> Unit,
    onRemoveTile: (TileSpec) -> Unit,
    coroutineScope: CoroutineScope,
@@ -909,7 +934,7 @@ private fun LazyGridItemScope.TileGridCell(
    modifier: Modifier = Modifier,
) {
    val stateDescription = stringResource(id = R.string.accessibility_qs_edit_position, index + 1)
    val tileState by rememberTileState(cell.tile, selectionState)
    val tileState by rememberTileState(cell.tile, selectionState, canRemoveTile)
    val resizingState = rememberResizingState(cell.tile.tileSpec, cell.isIcon)
    val progress: () -> Float = {
        if (tileState == TileState.Selected) {
@@ -969,6 +994,10 @@ private fun LazyGridItemScope.TileGridCell(
    // the resizing animation. The selection can't change positions without selecting a
    // different tile, so this isn't needed regardless.
    val placementSpec = if (tileState != TileState.Selected) TilePlacementSpec else null
    val removeTile = {
        selectionState.unSelect()
        onRemoveTile(cell.tile.tileSpec)
    }
    InteractiveTileContainer(
        tileState = tileState,
        resizingState = resizingState,
@@ -976,8 +1005,7 @@ private fun LazyGridItemScope.TileGridCell(
            modifier.height(TileHeight).fillMaxWidth().animateItem(placementSpec = placementSpec),
        onClick = {
            if (tileState == TileState.Removable) {
                selectionState.unSelect()
                onRemoveTile(cell.tile.tileSpec)
                removeTile()
            } else if (tileState == TileState.Selected) {
                coroutineScope.launch { resizingState.toggleCurrentValue() }
            }
@@ -993,10 +1021,11 @@ private fun LazyGridItemScope.TileGridCell(
        // Rapidly composing elements with the draggable modifier can cause visual jank. This
        // usually happens when resizing a tile multiple times. We can fix this by applying the
        // draggable modifier after the first frame
        var isReadyToDrag by remember { mutableStateOf(false) }
        LaunchedEffect(Unit) { isReadyToDrag = true }
        val draggableModifier =
            Modifier.dragAndDropTileSource(
        var isSelectable by remember { mutableStateOf(false) }
        LaunchedEffect(canLayoutTile) { isSelectable = canLayoutTile }
        val selectableModifier =
            Modifier.selectableTile(cell.tile.tileSpec, selectionState)
                .dragAndDropTileSource(
                    SizedTileImpl(cell.tile, cell.width),
                    dragAndDropState,
                    DragType.Move,
@@ -1011,6 +1040,7 @@ private fun LazyGridItemScope.TileGridCell(
                    this.stateDescription = stateDescription
                    contentDescription = cell.tile.label.text

                    if (isSelectable) {
                        val actions =
                            mutableListOf(
                                CustomAccessibilityAction(togglePlacementModeLabel) {
@@ -1044,8 +1074,12 @@ private fun LazyGridItemScope.TileGridCell(

                        customActions = actions
                    }
                .selectableTile(cell.tile.tileSpec, selectionState)
                .thenIf(isReadyToDrag) { draggableModifier }
                }
                .thenIf(isSelectable) { selectableModifier }
                .thenIf(!isSelectable && canRemoveTile && cell.tile.isRemovable && !canLayoutTile) {
                    // Set the remove click on the entire tile if reordering is disabled
                    Modifier.clickable(onClick = removeTile)
                }
                .tileBackground { backgroundColor }
        ) {
            EditTile(
+103 −71
Original line number Diff line number Diff line
@@ -16,7 +16,6 @@

package com.android.systemui.qs.panels.ui.compose.selection

import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
@@ -39,12 +38,12 @@ import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@@ -76,10 +75,8 @@ import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.Bad
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeSize
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeXOffset
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeYOffset
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.RESIZING_PILL_ANGLE_RAD
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.ResizingPillHeight
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.ResizingPillWidth
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.SelectedBorderWidth
import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.decoration
import com.android.systemui.qs.panels.ui.compose.selection.TileState.GreyedOut
import com.android.systemui.qs.panels.ui.compose.selection.TileState.None
import com.android.systemui.qs.panels.ui.compose.selection.TileState.Placeable
@@ -111,16 +108,14 @@ fun InteractiveTileContainer(
    contentDescription: String? = null,
    content: @Composable BoxScope.() -> Unit = {},
) {
    val transition: Transition<TileState> = updateTransition(tileState)
    val transition: Transition<Decoration> = updateTransition(tileState.decoration())
    val decorationColor by transition.animateColor()
    val decorationAngle by animateAngle(tileState)
    val decorationSize by transition.animateSize()
    val decorationOffset by transition.animateOffset()
    val decorationAlpha by
        transition.animateFloat { state -> if (state == Removable || state == Selected) 1f else 0f }
    val badgeIconAlpha by transition.animateFloat { state -> if (state == Removable) 1f else 0f }
    val selectionBorderAlpha by
        transition.animateFloat { state -> if (state == Selected) 1f else 0f }
    val decorationAngle by transition.animateAngle()
    val decorationSize by transition.animateSize { it.size }
    val decorationOffset by transition.animateOffset { it.offset }
    val decorationAlpha by transition.animateFloat { it.alpha }
    val badgeIconAlpha by transition.animateFloat { it.iconAlpha }
    val selectionBorderAlpha by transition.animateFloat { it.borderAlpha }
    val isIdle = transition.currentState == transition.targetState
    val isDraggable = tileState == Selected

@@ -307,19 +302,6 @@ enum class TileState {
    GreyedOut,
}

@Composable
private fun Transition<TileState>.animateColor(): State<Color> {
    return animateColor { state ->
        when (state) {
            None,
            GreyedOut -> Color.Transparent
            Removable -> MaterialTheme.colorScheme.primaryContainer
            Selected,
            Placeable -> MaterialTheme.colorScheme.primary
        }
    }
}

/**
 * Animate the angle of the tile decoration based on the previous state
 *
@@ -327,66 +309,74 @@ private fun Transition<TileState>.animateColor(): State<Color> {
 * between visible states.
 */
@Composable
private fun animateAngle(tileState: TileState): State<Float> {
private fun Transition<Decoration>.animateAngle(): State<Float> {
    val animatable = remember { Animatable(0f) }
    var animate by remember { mutableStateOf(false) }
    LaunchedEffect(tileState) {
        val targetAngle = tileState.decorationAngle()

        if (targetAngle == null) {
            animate = false
        } else {
            if (animate) animatable.animateTo(targetAngle) else animatable.snapTo(targetAngle)
            animate = true
    val target = targetState
    LaunchedEffect(target) {
        if (target is VisibleDecoration) {
            // Snap to the target angle when the current decoration isn't visible
            when (currentState) {
                is NoDecoration -> animatable.snapTo(target.angle)
                is VisibleDecoration -> animatable.animateTo(target.angle)
            }
        }
    }
    return animatable.asState()
}

/**
 * Animate the color of the tile decoration based on the previous state
 *
 * Some [TileState] don't have a visible decoration, and the color should only animate when going
 * between visible states.
 */
@Composable
private fun Transition<TileState>.animateSize(): State<Size> {
    return animateSize { state ->
        with(LocalDensity.current) {
            when (state) {
                None,
                Placeable,
                GreyedOut -> Size.Zero
                Removable -> Size(BadgeSize.toPx())
                Selected -> Size(ResizingPillWidth.toPx(), ResizingPillHeight.toPx())
private fun Transition<Decoration>.animateColor(): State<Color> {
    val animatable = remember { androidx.compose.animation.Animatable(Color.Transparent) }
    val target = targetState
    LaunchedEffect(target) {
        if (target is VisibleDecoration) {
            // Snap to the target color when the current decoration isn't visible
            when (currentState) {
                is NoDecoration -> animatable.snapTo(target.color)
                is VisibleDecoration -> animatable.animateTo(target.color)
            }
        }
    }
    return animatable.asState()
}

@Composable
private fun Transition<TileState>.animateOffset(): State<Offset> {
    return animateOffset { state ->
        with(LocalDensity.current) {
            when (state) {
                None,
                Placeable,
                GreyedOut -> Offset.Zero
                Removable -> Offset(BadgeXOffset.toPx(), BadgeYOffset.toPx())
                Selected -> Offset(-SelectedBorderWidth.toPx(), 0f)
            }
        }
    }
}
private fun Size(size: Float) = Size(size, size)

private fun TileState.decorationAngle(): Float? {
    return when (this) {
        Removable -> BADGE_ANGLE_RAD
        Selected -> RESIZING_PILL_ANGLE_RAD
        None,
        Placeable,
        GreyedOut -> null // No visible decoration
private fun offsetForAngle(angle: Float, radius: Float, center: Offset): Offset {
    return Offset(x = radius * cos(angle) + center.x, y = radius * sin(angle) + center.y)
}

@Stable
sealed interface Decoration {
    val alpha: Float
    val iconAlpha: Float
    val borderAlpha: Float
    val size: Size
    val offset: Offset
}

private fun Size(size: Float) = Size(size, size)
private data class VisibleDecoration(
    override val alpha: Float = 1f,
    override val iconAlpha: Float,
    override val borderAlpha: Float,
    override val size: Size,
    override val offset: Offset,
    val color: Color,
    val angle: Float,
) : Decoration

private fun offsetForAngle(angle: Float, radius: Float, center: Offset): Offset {
    return Offset(x = radius * cos(angle) + center.x, y = radius * sin(angle) + center.y)
private data object NoDecoration : Decoration {
    override val alpha: Float = 0f
    override val iconAlpha: Float = 0f
    override val borderAlpha: Float = 0f
    override val size: Size = Size.Zero
    override val offset: Offset = Offset.Zero
}

private object SelectionDefaults {
@@ -399,4 +389,46 @@ private object SelectionDefaults {
    val ResizingPillHeight = 16.dp
    const val BADGE_ANGLE_RAD = -.8f
    const val RESIZING_PILL_ANGLE_RAD = 0f

    @Composable
    @ReadOnlyComposable
    fun TileState.decoration(): Decoration {
        return when (this) {
            Removable -> removalBadge()
            Selected -> resizingHandle()
            None,
            Placeable,
            GreyedOut -> NoDecoration
        }
    }

    @Composable
    @ReadOnlyComposable
    fun removalBadge(): VisibleDecoration {
        return with(LocalDensity.current) {
            VisibleDecoration(
                iconAlpha = 1f,
                borderAlpha = 0f,
                color = MaterialTheme.colorScheme.primaryContainer,
                size = Size(BadgeSize.toPx()),
                angle = BADGE_ANGLE_RAD,
                offset = Offset(BadgeXOffset.toPx(), BadgeYOffset.toPx()),
            )
        }
    }

    @Composable
    @ReadOnlyComposable
    fun resizingHandle(): VisibleDecoration {
        return with(LocalDensity.current) {
            VisibleDecoration(
                iconAlpha = 0f,
                borderAlpha = 1f,
                color = MaterialTheme.colorScheme.primary,
                size = Size(ResizingPillWidth.toPx(), ResizingPillHeight.toPx()),
                angle = RESIZING_PILL_ANGLE_RAD,
                offset = Offset(-SelectedBorderWidth.toPx(), 0f),
            )
        }
    }
}
+160 −1
Original line number Diff line number Diff line
@@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsActions.CustomActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -50,6 +53,7 @@ import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.qs.shared.model.TileCategory
import com.android.systemui.res.R
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
@@ -69,8 +73,9 @@ class EditModeTest : SysuiTestCase() {
        val allTiles = remember { TestEditTiles.toMutableStateList() }
        val largeTiles = remember { TestLargeTilesSpecs.toMutableStateList() }
        val currentTiles = allTiles.filter { it.isCurrent }
        val listState =
        val listState = remember {
            EditTileListState(currentTiles, TestLargeTilesSpecs, columns = 4, largeTilesSpan = 2)
        }
        LaunchedEffect(currentTiles, largeTiles) {
            listState.updateTiles(currentTiles, largeTiles.toSet())
        }
@@ -130,6 +135,63 @@ class EditModeTest : SysuiTestCase() {
    }

    @Test
    @DisableFlags(QsEditModeTabs.FLAG_NAME)
    fun clickCurrentTile_shouldRemove() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Tap to remove
        composeRule
            .onAllNodesWithContentDescription(
                context.getString(R.string.accessibility_qs_edit_remove_tile_action)
            )
            .onFirst()
            .performClick()

        composeRule.assertCurrentTilesGridContainsExactly(
            listOf("tileB", "tileC", "tileD_large", "tileE")
        )
        composeRule.assertAvailableTilesGridContainsExactly(TestEditTiles.map { it.tileSpec.spec })
    }

    @Test
    @EnableFlags(QsEditModeTabs.FLAG_NAME)
    fun clickCurrentTile_inAddTab_shouldRemove() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Tap to remove
        composeRule
            .onAllNodesWithContentDescription(
                context.getString(R.string.accessibility_qs_edit_remove_tile_action)
            )
            .onFirst()
            .performClick()

        // Assert tileA is missing
        composeRule.assertCurrentTilesGridContainsExactly(
            listOf("tileB", "tileC", "tileD_large", "tileE")
        )
        composeRule.assertAvailableTilesGridContainsExactly(TestEditTiles.map { it.tileSpec.spec })
    }

    @Test
    @EnableFlags(QsEditModeTabs.FLAG_NAME)
    fun clickCurrentTile_inLayoutTab_shouldNotRemove() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Tap to remove
        composeRule.onNodeWithText("tileA").performClick()

        // Assert tileA is not missing
        composeRule.assertCurrentTilesGridContainsExactly(
            listOf("tileA", "tileB", "tileC", "tileD_large", "tileE")
        )
    }

    @Test
    @DisableFlags(QsEditModeTabs.FLAG_NAME)
    fun placementMode_shouldRepositionTile() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()
@@ -147,6 +209,28 @@ class EditModeTest : SysuiTestCase() {
    }

    @Test
    @EnableFlags(QsEditModeTabs.FLAG_NAME)
    fun placementMode_inLayoutTab_shouldRepositionTile() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Tap on Layout tab to select
        composeRule.onNodeWithText("Layout").performClick()

        // Double tap first "tileA", i.e. the one in the current grid
        composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() }

        // Tap on tileE to position tileA in its spot
        composeRule.onNodeWithContentDescription("tileE").performClick()

        // Assert tileA moved to tileE's position
        composeRule.assertCurrentTilesGridContainsExactly(
            listOf("tileB", "tileC", "tileD_large", "tileE", "tileA")
        )
    }

    @Test
    @DisableFlags(QsEditModeTabs.FLAG_NAME)
    fun resizingAction_dependsOnPlacementMode() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()
@@ -172,6 +256,52 @@ class EditModeTest : SysuiTestCase() {
    }

    @Test
    @EnableFlags(QsEditModeTabs.FLAG_NAME)
    fun resizingAction_inLayoutTab_dependsOnPlacementMode() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Tap on Layout tab to select
        composeRule.onNodeWithText("Layout").performClick()

        // Use the toggle size action
        composeRule
            .onNodeWithContentDescription("tileE")
            .performCustomAccessibilityActionWithLabel(
                context.getString(R.string.accessibility_qs_edit_toggle_tile_size_action)
            )

        // Double tap "tileA" to enable placement mode
        composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() }

        // Assert the toggle size action is missing
        assertThrows(AssertionError::class.java) {
            composeRule
                .onNodeWithContentDescription("tileE")
                .performCustomAccessibilityActionWithLabel(
                    context.getString(R.string.accessibility_qs_edit_toggle_tile_size_action)
                )
        }
    }

    @Test
    @EnableFlags(QsEditModeTabs.FLAG_NAME)
    fun layoutActions_inAddTab_doNotExist() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Assert the toggle size action is missing from the "Add" tab
        val hasCustomActions =
            composeRule
                .onNodeWithContentDescription("tileE")
                .fetchSemanticsNode()
                .config
                .contains(CustomActions)
        assertThat(hasCustomActions).isFalse()
    }

    @Test
    @DisableFlags(QsEditModeTabs.FLAG_NAME)
    fun placementAction_dependsOnPlacementMode() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()
@@ -196,6 +326,35 @@ class EditModeTest : SysuiTestCase() {
            )
    }

    @Test
    @EnableFlags(QsEditModeTabs.FLAG_NAME)
    fun placementAction_inLayoutTab_dependsOnPlacementMode() {
        composeRule.setContent { EditTileGridUnderTest() }
        composeRule.waitForIdle()

        // Tap on Layout tab to select
        composeRule.onNodeWithText("Layout").performClick()

        // Assert the placement action is missing
        assertThrows(AssertionError::class.java) {
            composeRule
                .onNodeWithContentDescription("tileE")
                .performCustomAccessibilityActionWithLabel(
                    context.getString(R.string.accessibility_qs_edit_place_tile_action)
                )
        }

        // Double tap "tileA" to enable placement mode
        composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() }

        // Use the placement action
        composeRule
            .onNodeWithContentDescription("tileE")
            .performCustomAccessibilityActionWithLabel(
                context.getString(R.string.accessibility_qs_edit_place_tile_action)
            )
    }

    @Test
    fun performAction_undoAppears() {
        composeRule.setContent { EditTileGridUnderTest() }