diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index 5b60467..e3a1c4f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -75,7 +75,7 @@ fun CarAppStage( // the template it described, laid over the top. androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) { if (status.surfaceRequested) { - AppSurface(connection, frame, template, Modifier.fillMaxSize()) + AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize()) } // The docked player floats over one side of the map: the chrome keeps clear of it, // and the app hears that side is covered, so its map centres in the rest. @@ -116,6 +116,7 @@ private fun AppSurface( connection: CarAppConnection, frame: CarEnvironment.Frame, template: Template?, + embedded: Boolean, modifier: Modifier = Modifier, ) { var size by remember { mutableStateOf(0 to 0) } @@ -125,6 +126,19 @@ private fun AppSurface( // The app lays its map out around whatever the host covers, so a template change or a card // changing size moves the visible area even though the surface itself hasn't changed. val (width, height) = size + // A full-screen map takes the knob: turn to zoom, nudge to pan, press for its buttons. The + // home screen's map pane leaves it alone, so the knob still moves between the cards there. + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + val isMap = !embedded && !isContentTemplate(template) + DisposableEffect(connection, isMap, width, height) { + if (!isMap || width == 0 || height == 0) return@DisposableEffect onDispose {} + val knob = MapKnob(connection, width, height) { focusManager.clearFocus() } + CarKeys.knobTaker = knob + onDispose { + knob.release() + if (CarKeys.knobTaker === knob) CarKeys.knobTaker = null + } + } val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) } // The card column moves the map's centre but isn't taken out of the stable area: apps size // their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt index 3eed2a1..41cd676 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt @@ -35,6 +35,24 @@ object CarKeys { /** Delivers an Android key event to the car display; set while the car UI is showing. */ @Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null + /** What a [KnobTaker] is offered: a turn of the knob, a focus key edge, or Back. */ + sealed interface KnobEvent { + data class Turn(val steps: Int) : KnobEvent + data class Key(val keyCode: Int, val down: Boolean) : KnobEvent + data object Back : KnobEvent + } + + /** + * Something on screen that wants the knob before focus moves: the full-screen map, which + * zooms and pans with it ([MapKnob]), and the app grid, which starts on the app opened last. + * Returns true for an event it kept. Called on the thread the keys arrive on, not the main one. + */ + fun interface KnobTaker { + fun take(event: KnobEvent): Boolean + } + + @Volatile var knobTaker: KnobTaker? = null + private var appContext: Context? = null private val main = Handler(Looper.getMainLooper()) @@ -63,6 +81,7 @@ object CarKeys { */ fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { if (keyCode in FOCUS_KEYS) { + if (knobTaker?.take(KnobEvent.Key(keyCode, down)) == true) return true _focusMoves.tryEmit(Unit) focusSink?.invoke(keyCode, down) return true @@ -78,7 +97,7 @@ object CarKeys { KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice() KeyEvent.KEYCODE_CALL -> call() KeyEvent.KEYCODE_ENDCALL -> endCall() - KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK) + KeyEvent.KEYCODE_BACK -> if (knobTaker?.take(KnobEvent.Back) != true) screen(ScreenKey.BACK) KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME) KEYCODE_NAVIGATION -> screen(ScreenKey.MAP) KEYCODE_MEDIA -> screen(ScreenKey.MEDIA) @@ -92,6 +111,7 @@ object CarKeys { /** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */ fun onRotate(delta: Int) { val sink = focusSink ?: return + if (knobTaker?.take(KnobEvent.Turn(delta)) == true) return _focusMoves.tryEmit(Unit) val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index fb94067..caeac98 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -16,6 +16,16 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import app.seb3thehacker.gearslip.AppSettings +import kotlinx.coroutines.launch import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -75,6 +85,8 @@ private class Tile( /** Known not to work; drawn with a red X instead of being left off the launcher. */ val broken: Boolean = false, val onLongClick: (() -> Unit)? = null, + /** The entry's [Entry.componentId], for finding the app opened last. */ + val id: String? = null, val onClick: () -> Unit, ) @@ -290,21 +302,53 @@ fun CarLauncher() { entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph, broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))), onLongClick = id?.let { { navigator.showAppMenu(it) } }, - ) { openApp(entry, id?.let { running[it] }, navigator, frame) } + id = id, + ) { + id?.let { AppSettings.putString(context, KEY_LAST_APP, it) } + openApp(entry, id?.let { running[it] }, navigator, frame) + } } val appOrder by CarSettings.appOrder.collectAsState() val hidden by CarSettings.hiddenApps.collectAsState() val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf) + // With the knob, the first move on this screen lands on the app opened last rather than on + // the first button anywhere on screen (issue #74). After that the knob moves as usual, out to + // the nav bar and back; nudging up or down already jumps a row. + val gridState = rememberLazyGridState() + val scope = rememberCoroutineScope() + val lastFocus = remember { FocusRequester() } + val lastId = remember { AppSettings.getString(context, KEY_LAST_APP, "") } + val lastIndex = tiles.indexOfFirst { it.id == lastId }.coerceAtLeast(0) + val focusIndex by rememberUpdatedState(lastIndex) + DisposableEffect(Unit) { + var used = false + val taker = CarKeys.KnobTaker { event -> + if (used || event == CarKeys.KnobEvent.Back || (event is CarKeys.KnobEvent.Key && !event.down)) { + return@KnobTaker false + } + used = true + scope.launch { + gridState.scrollToItem(focusIndex) + withFrameNanos { } + runCatching { lastFocus.requestFocus() } + } + true + } + CarKeys.knobTaker = taker + onDispose { if (CarKeys.knobTaker === taker) CarKeys.knobTaker = null } + } + LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 108.dp), + state = gridState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(20.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(tiles) { AppTile(it) } + itemsIndexed(tiles) { i, tile -> AppTile(tile, if (i == lastIndex) Modifier.focusRequester(lastFocus) else Modifier) } } } @@ -321,14 +365,14 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = @OptIn(ExperimentalFoundationApi::class) @Composable -private fun AppTile(tile: Tile) { +private fun AppTile(tile: Tile, modifier: Modifier = Modifier) { val shape = MaterialTheme.shapes.extraLarge // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are // shown, here and on the nav bar. GsIconBox marks it in the theme's style. GsIconBox( onClick = tile.onClick, onLongClick = tile.onLongClick, - modifier = Modifier.height(120.dp), + modifier = modifier.height(120.dp), colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), shape = shape, ) { @@ -377,3 +421,6 @@ private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifi overflow = TextOverflow.Ellipsis, ) } + +/** The app opened from the launcher last, where the knob starts on the Apps screen. */ +private const val KEY_LAST_APP = "car_last_app" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapKnob.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapKnob.kt new file mode 100644 index 0000000..29c9226 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapKnob.kt @@ -0,0 +1,111 @@ +package app.seb3thehacker.gearslip.car + +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import app.seb3thehacker.gearslip.host.CarAppConnection +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.min +import kotlin.math.pow + +/** + * The knob on a full-screen map, as in Android Auto: turning zooms, nudging pans, and pressing + * brings up the map's buttons for the knob to move between. Back puts them away and gives the + * knob back to the map. Issue #74: on a Mazda 3, every turn stepped through each map button. + * + * [width] and [height] are the app's surface in pixels; zooms centre on its middle. + * [clearFocus] drops focus off whatever button the knob was on. + */ +internal class MapKnob( + private val connection: CarAppConnection, + private val width: Int, + private val height: Int, + private val clearFocus: () -> Unit, +) : CarKeys.KnobTaker { + + private val main = Handler(Looper.getMainLooper()) + /** The press that opened the buttons lifts afterwards; that lift belongs to no button. */ + @Volatile private var swallowLift = false + + override fun take(event: CarKeys.KnobEvent): Boolean { + // A card over the map with buttons of its own (an app's alert, a call, a message, the + // assistant, Bluetooth pairing) needs the knob more than the map does. + if (overlayUp()) return false + if (_buttonsOpen.value) { + if (event is CarKeys.KnobEvent.Key && !event.down && swallowLift && event.keyCode in PRESS_KEYS) { + swallowLift = false + return true + } + if (event == CarKeys.KnobEvent.Back) { + main.post { close() } + return true + } + return false + } + when (event) { + is CarKeys.KnobEvent.Turn -> main.post { zoom(event.steps) } + is CarKeys.KnobEvent.Key -> when (event.keyCode) { + in PRESS_KEYS -> if (event.down) main.post { open() } + else -> if (event.down) main.post { pan(event.keyCode) } + } + CarKeys.KnobEvent.Back -> return false + } + return true + } + + private fun overlayUp(): Boolean = + CarAlerts.shown.value != null || + app.seb3thehacker.gearslip.call.CarCalls.state.value is app.seb3thehacker.gearslip.call.CallUiState.Ringing || + app.seb3thehacker.gearslip.notify.CarNotifications.popup.value != null || + CarAssistant.state.value.phase != CarAssistant.Phase.IDLE || + CarBluetooth.prompt.value != CarBluetooth.Prompt.NONE + + private fun zoom(steps: Int) { + val clicks = steps.coerceIn(-MAX_STEPS, MAX_STEPS) + connection.surfaceScale(width / 2f, height / 2f, ZOOM_STEP.pow(clicks)) + } + + /** One nudge moves the map a sixth of the screen. Scroll distances run previous minus current. */ + private fun pan(keyCode: Int) { + val step = min(width, height) / 6f + when (keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> connection.surfaceScroll(0f, -step) + KeyEvent.KEYCODE_DPAD_DOWN -> connection.surfaceScroll(0f, step) + KeyEvent.KEYCODE_DPAD_LEFT -> connection.surfaceScroll(-step, 0f) + KeyEvent.KEYCODE_DPAD_RIGHT -> connection.surfaceScroll(step, 0f) + } + } + + private fun open() { + swallowLift = true + _buttonsOpen.value = true + // The map's buttons take focus when they see this. A map showing none gets a Tab, so the + // knob still lands somewhere. + if (controls == 0) CarKeys.focusSink?.let { it(KeyEvent.KEYCODE_TAB, true); it(KeyEvent.KEYCODE_TAB, false) } + } + + private fun close() { + _buttonsOpen.value = false + clearFocus() + } + + /** Puts the buttons away when the map leaves the screen. */ + fun release() { + _buttonsOpen.value = false + } + + companion object { + private val _buttonsOpen = MutableStateFlow(false) + + /** The knob pressed on the map: its buttons show and take focus, until Back. */ + val buttonsOpen: StateFlow = _buttonsOpen + + /** How many stacks of map buttons are on screen, for [open] to know whether any will take focus. */ + @Volatile var controls = 0 + + private const val ZOOM_STEP = 1.25f + private const val MAX_STEPS = 5 + private val PRESS_KEYS = setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index ff40202..b72fc5d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -7,6 +7,8 @@ import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.focusRequester +import androidx.compose.foundation.focusGroup import androidx.compose.material3.LocalContentColor import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.luminance @@ -249,7 +251,17 @@ internal fun RightEdgeControls( val pan by connection.panMode.collectAsState() // Kept up while the knob is on one of them, or the ring would fade out along with the button. var holdsFocus by remember { mutableStateOf(false) } - val visible = awake || pan || holdsFocus + // Pressing the knob on the map hands it to these buttons (see MapKnob). + val knobOpen by MapKnob.buttonsOpen.collectAsState() + val knobFocus = remember { androidx.compose.ui.focus.FocusRequester() } + androidx.compose.runtime.DisposableEffect(Unit) { + MapKnob.controls++ + onDispose { MapKnob.controls-- } + } + androidx.compose.runtime.LaunchedEffect(knobOpen) { + if (knobOpen) runCatching { knobFocus.requestFocus() } + } + val visible = awake || pan || holdsFocus || knobOpen // With the player beside the map there is no room for the app's row and the map controls // both, so the app's own buttons fold behind one; opening it swaps them in for the map's. val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1 @@ -263,6 +275,8 @@ internal fun RightEdgeControls( Column( modifier .alpha(alpha) + .focusRequester(knobFocus) + .focusGroup() .onFocusChanged { holdsFocus = it.hasFocus } .let { base -> if (visible) base else base.pointerInput(Unit) {