diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index 8474050c3..f6fa01c8f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -3,6 +3,8 @@ package com.droiddeck.launcher.ui import com.droiddeck.launcher.R import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot @@ -19,6 +21,7 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.ExpandLess import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString @@ -209,7 +212,7 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) { private const val STALE_MS = 6 * 3_600_000L -@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable internal fun GpuDriversBody( gpu: GpuDriversState, @@ -224,6 +227,13 @@ internal fun GpuDriversBody( } val scroll = rememberScrollState() val scope = rememberCoroutineScope() + val driverControls = remember { BringIntoViewRequester() } + LaunchedEffect(gpu.mode) { + if (gpu.mode == GPU_MODE_MIX) { + withFrameNanos { } + driverControls.bringIntoView() + } + } val placed = remember { arrayOfNulls(2) } // Focusing the top mode shows as much of the in-use card above it as fits, without hiding the mode itself. val revealCard = { @@ -251,7 +261,10 @@ internal fun GpuDriversBody( stringResource(title), stringResource(detail), gpu.mode == id, modifier = mod.then(if (gpu.mode == id) Modifier.focusRequester(first) else Modifier) .then(if (i == 0) Modifier.onGloballyPositioned { placed[1] = it }.onFocusChanged { if (it.isFocused) revealCard() } else Modifier), - onClick = { if (gpu.mode != id) actions.onMode(id) }, + onClick = { + if (gpu.mode != id) actions.onMode(id) + else if (id == GPU_MODE_MIX) scope.launch { driverControls.bringIntoView() } + }, ) } if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { modes.indices.forEach { choice(it, Modifier) } } @@ -261,7 +274,7 @@ internal fun GpuDriversBody( when (gpu.mode) { GPU_MODE_AUTO -> AutoBody(gpu) GPU_MODE_PAIR -> PairBody(gpu, actions) - else -> MixBody(single, actions, slotFocus, onOpenSlot) + else -> MixBody(single, actions, slotFocus, onOpenSlot, Modifier.bringIntoViewRequester(driverControls)) } gpu.tpUbwcMode?.let { selected -> var advanced by rememberSaveable { mutableStateOf(false) } @@ -395,7 +408,7 @@ private fun PairBody(gpu: GpuDriversState, actions: GpuDriversActions) { } @Composable -private fun MixBody(single: Boolean, actions: GpuDriversActions, slotFocus: Map, onOpenSlot: (String, Rect) -> Unit) { +private fun MixBody(single: Boolean, actions: GpuDriversActions, slotFocus: Map, onOpenSlot: (String, Rect) -> Unit, controlsModifier: Modifier) { val pal = LocalPalette.current Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { PanelTitle(stringResource(R.string.gpu_mix_title), stringResource(R.string.gpu_mix_hint)) @@ -410,8 +423,8 @@ private fun MixBody(single: Boolean, actions: GpuDriversActions, slotFocus: Map< slotButton(SLOT_DISPLAY, R.string.gpu_change_display, mod) slotButton(SLOT_RUNTIME, R.string.gpu_change_runtime, mod) } - if (single) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { buttons(Modifier.fillMaxWidth()) } - else Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { buttons(Modifier.weight(1f)) } + if (single) Column(controlsModifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { buttons(Modifier.fillMaxWidth()) } + else Row(controlsModifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { buttons(Modifier.weight(1f)) } FocusText(stringResource(R.string.comp_import_zip), pal.signal, onClick = actions.onImportZip) } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GpuDriverControlsVisibilityTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GpuDriverControlsVisibilityTest.kt new file mode 100644 index 000000000..f0252b557 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GpuDriverControlsVisibilityTest.kt @@ -0,0 +1,71 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], qualifiers = "w960dp-h400dp-land") +class GpuDriverControlsVisibilityTest { + @get:Rule val compose = createRobolectricComposeRule() + private var openedSlot: String? = null + + @Test fun openingMixedModeRevealsBothDriverButtonsOnAShortScreen() { + show(GPU_MODE_MIX) + assertControlsVisible() + compose.onNodeWithText(text(R.string.gpu_change_display)).performClick() + assertEquals(SLOT_DISPLAY, openedSlot) + } + + @Test fun selectingMixedModeRevealsItsControlsBelowTheModeChoices() { + show(GPU_MODE_AUTO) + compose.onNodeWithText(text(R.string.gpu_mode_mix)).performScrollTo().performClick() + assertControlsVisible() + compose.onNodeWithText(text(R.string.gpu_change_runtime)).performClick() + assertEquals(SLOT_RUNTIME, openedSlot) + } + + private fun assertControlsVisible() { + compose.onNodeWithText(text(R.string.gpu_change_display)).assertIsDisplayed() + compose.onNodeWithText(text(R.string.gpu_change_runtime)).assertIsDisplayed() + } + + private fun text(id: Int) = RuntimeEnvironment.getApplication().getString(id) + + private fun show(initialMode: String) { + val mode = mutableStateOf(initialMode) + val first = FocusRequester() + val slots = mapOf(SLOT_DISPLAY to FocusRequester(), SLOT_RUNTIME to FocusRequester()) + compose.setContent { + DroidDeckTheme { + Box(Modifier.requiredSize(480.dp, 280.dp)) { + GpuDriversBody( + gpu = GpuDriversState( + gpuName = "Adreno 830", gpuFamily = "Adreno 8xx", mode = mode.value, + display = DriverSlot(name = "Automatic", status = "Applies after restarting DroidDeck"), + runtime = DriverSlot(name = "System Qualcomm driver", status = "Applies at the next session start"), + ), + actions = GpuDriversActions(onMode = { mode.value = it }), + single = true, first = first, slotFocus = slots, + onOpenSlot = { slot, _ -> openedSlot = slot }, + ) + } + } + } + } +}