From 554bb4922f20db837f1087409c571e0463410ffa Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 4 Nov 2026 22:59:22 -0800 Subject: [PATCH] The knob zooms and pans a full-screen map, and starts on the last app in the grid As in Android Auto, turning zooms the map, nudging pans it, and pressing brings up its buttons for the knob to move between; Back puts them away. A card over the map with buttons of its own keeps the knob. On the Apps screen the first knob move lands on the app opened last. Issue #74: a Mazda 3 driver had to turn through every button to get anywhere. --- .../seb3thehacker/gearslip/car/CarAppStage.kt | 16 ++- .../app/seb3thehacker/gearslip/car/CarKeys.kt | 22 +++- .../seb3thehacker/gearslip/car/CarLauncher.kt | 55 ++++++++- .../app/seb3thehacker/gearslip/car/MapKnob.kt | 111 ++++++++++++++++++ .../gearslip/car/TemplateParts.kt | 16 ++- 5 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapKnob.kt 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) {