mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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:
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) {
|
||||
|
||||
Reference in new issue
Block a user