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.
This commit is contained in:
Seb3thehacker committed 2026-11-04 22:59:22 -08:00
1 parent 4ac389d2fd
commit 554bb4922f
5 files changed
+213 -7

No files matched your search

@@ -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.
@@ -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)) {
@@ -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"
@@ -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<Boolean> = _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)
}
}
@@ -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) {