From e843f2adfe1cb7eb957ea361abf393d8e07295e5 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sat, 10 Oct 2026 17:55:36 -0700 Subject: [PATCH] car knob: move focus with the rotary knob, show where it is The knob's turns reached the car screen but never moved focus: keys handed straight to the view left it in touch mode, where buttons refuse focus. Buttons now show an accent ring while the knob is on them, map buttons wake on a knob turn instead of only on touch, and web pages get the same ring. The car's Home button opens the dashboard, Map opens the map, and Music reopens the last media app. Refs #9 --- .../seb3thehacker/gearslip/ScreenProjector.kt | 6 ++ .../app/seb3thehacker/gearslip/car/CarKeys.kt | 7 ++ .../seb3thehacker/gearslip/car/CarServices.kt | 13 +++ .../app/seb3thehacker/gearslip/car/CarUi.kt | 13 ++- .../seb3thehacker/gearslip/car/FocusRing.kt | 84 +++++++++++++++++++ .../gearslip/car/TemplateParts.kt | 6 +- .../app/seb3thehacker/gearslip/car/WebApp.kt | 22 ++++- 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt index 7a29fc6..c9ffee5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt @@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) { */ fun dispatchKey(keyCode: Int, down: Boolean) { val target = root ?: return + // Keys handed straight to the view skip the step where Android leaves touch mode, and in + // touch mode Compose's buttons refuse focus, so the knob had nothing to move between. + if (target.isInTouchMode) { + target.requestFocusFromTouch() + GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})") + } // Nothing on the display takes keys until something in it holds focus. if (target.findFocus() == null) target.requestFocus() val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB 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 3eeae41..ed4b4f0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt @@ -27,6 +27,11 @@ object CarKeys { private val _screenKeys = MutableSharedFlow(extraBufferCapacity = 8) val screenKeys: SharedFlow = _screenKeys + private val _focusMoves = MutableSharedFlow(extraBufferCapacity = 1) + + /** A knob turn or D-pad press, so controls that hide when left alone can come back. */ + val focusMoves: SharedFlow = _focusMoves + /** 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 @@ -58,6 +63,7 @@ object CarKeys { */ fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { if (keyCode in FOCUS_KEYS) { + _focusMoves.tryEmit(Unit) focusSink?.invoke(keyCode, down) return true } @@ -86,6 +92,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 + _focusMoves.tryEmit(Unit) val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { sink(code, true) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 5f02ea4..6c6d920 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -127,6 +127,19 @@ object CarServices { if (CarSettings.pipeAudio.value) CarAudio.request() } + /** + * Reopens the media app used last, for the car's media button when nothing is open. False + * when there's none on record or it's been uninstalled since. + */ + suspend fun reopenLastMedia(): Boolean { + val ctx = context ?: return false + val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false + val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) } + .firstOrNull { it.component == last } ?: return false + openMedia(app) + return true + } + /** Closes the media app from the car: stops what it's playing and drops the connection. */ fun closeMedia() { if (media.now.value.playing) media.togglePlay() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index c21f6e2..d776d49 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -120,6 +121,8 @@ fun CarUi() { controlsWake.tick() } } + // The knob counts as a touch: the map's buttons can't be found while they're faded out. + LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } } remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } // Ask for the phone's sound as soon as the car connects, not only when a media app opens: @@ -152,8 +155,12 @@ fun CarUi() { VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel() else -> navigator.back() } - CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home() - CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps() + // Home is the dashboard, over the map, so Back from it lands on the map. + CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() } + CarKeys.ScreenKey.MAP -> navigator.home() + CarKeys.ScreenKey.MEDIA -> + if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media() + else navigator.apps() CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator) } } @@ -167,6 +174,7 @@ fun CarUi() { LocalGsTheme provides GsThemes.byId(uiThemeId), ) { MaterialTheme(colorScheme = colors) { + CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) { Surface( Modifier .fillMaxSize() @@ -256,6 +264,7 @@ fun CarUi() { if (safetyWarningSeen) CarWhatsNew() } } + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt new file mode 100644 index 0000000..bc063c5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt @@ -0,0 +1,84 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.material3.ripple +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * What every car button shows: the usual ripple when pressed, and a ring in the accent color + * while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes + * focus while the screen is in touch mode. + * + * It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that + * doesn't pass its own indication gets the ring for free. + */ +internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory { + private val ripple = ripple() + + override fun create(interactionSource: InteractionSource): DelegatableNode = + FocusRingNode(ripple.create(interactionSource), interactionSource, color) + + override fun equals(other: Any?) = other is FocusRingIndication && other.color == color + override fun hashCode() = color.hashCode() +} + +private class FocusRingNode( + rippleNode: DelegatableNode, + private val source: InteractionSource, + private val color: Color, +) : DelegatingNode(), DrawModifierNode { + private var focused = false + + init { delegate(rippleNode) } + + override fun onAttach() { + coroutineScope.launch { + var holding = 0 + source.interactions.collect { + when (it) { + is FocusInteraction.Focus -> holding++ + is FocusInteraction.Unfocus -> holding-- + } + val now = holding > 0 + if (now != focused) { + focused = now + invalidateDraw() + } + } + } + } + + override fun ContentDrawScope.draw() { + drawContent() + if (!focused) return + // Inside the button's bounds, since most buttons clip to their shape: drawn outside, a + // rounded tile would cut its own ring off. + val width = RING_WIDTH.toPx() + val radius = minOf(CORNER.toPx(), size.minDimension / 2) + drawRoundRect( + color, + topLeft = Offset(width / 2, width / 2), + size = Size(size.width - width, size.height - width), + cornerRadius = CornerRadius(radius), + style = Stroke(width), + ) + } + + private companion object { + val RING_WIDTH = 3.dp + val CORNER = 16.dp + } +} 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 cee1973..1111404 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car 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.material3.LocalContentColor import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.luminance @@ -233,7 +234,9 @@ internal fun RightEdgeControls( // the first touch on one wakes them rather than pressing it. val awake = LocalMapControlsWake.current.awake val pan by CarServices.nav.panMode.collectAsState() - val visible = awake || pan + // 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 val alpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (visible) 1f else 0f, animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), @@ -242,6 +245,7 @@ internal fun RightEdgeControls( Column( modifier .alpha(alpha) + .onFocusChanged { holdsFocus = it.hasFocus } .let { base -> if (visible) base else base.pointerInput(Unit) { awaitPointerEventScope { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index 868fbb7..7883fc1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.viewinterop.AndroidView import app.seb3thehacker.gearslip.GearslipLog import org.json.JSONObject @@ -38,6 +40,7 @@ import org.json.JSONObject fun WebApp(content: String) { val navigator = LocalCarNavigator.current val darkOutside = LocalDarkOutside.current + val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF) var web by remember { mutableStateOf(null) } var fieldText by remember { mutableStateOf(null) } @@ -65,7 +68,7 @@ fun WebApp(content: String) { settings.domStorageEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true - webViewClient = LoggingClient() + webViewClient = LoggingClient(ring) addJavascriptInterface( FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), "GearslipField", @@ -99,10 +102,11 @@ fun WebApp(content: String) { } } -private class LoggingClient : WebViewClient() { +private class LoggingClient(private val ring: String) : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { GearslipLog.i("car web page loaded: $url") view?.evaluateJavascript(FOCUS_TRACKING_JS, null) + view?.evaluateJavascript(focusRingJs(ring), null) } // onPageFinished fires for error pages too, so without this a blocked load (missing @@ -170,6 +174,20 @@ private const val SUBMIT_FIELD_JS = """ })(); """ +/** + * The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many + * sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are. + */ +private fun focusRingJs(color: String) = """ + (function() { + if (document.getElementById('gearslip-focus-ring')) return; + var style = document.createElement('style'); + style.id = 'gearslip-focus-ring'; + style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }'; + (document.head || document.documentElement).appendChild(style); + })(); +""" + /** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */ private const val FOCUS_TRACKING_JS = """ (function() {