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 108e292..ce16105 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -34,6 +34,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -59,13 +61,17 @@ fun CarAppStage( ) { val template by connection.template.collectAsStateWithLifecycle() + val cover = remember { ChromeCover() } + Box(Modifier.fillMaxSize()) { // The app's own drawing goes underneath; everything else is the host's rendering of // the template it described, laid over the top. - if (status.surfaceRequested) { - AppSurface(connection, frame, template, Modifier.fillMaxSize()) + androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover) { + if (status.surfaceRequested) { + AppSurface(connection, frame, template, Modifier.fillMaxSize()) + } + TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR)) } - TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR)) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) } } @@ -84,13 +90,16 @@ private fun AppSurface( modifier: Modifier = Modifier, ) { var size by remember { mutableStateOf(0 to 0) } + var bounds by remember { mutableStateOf(null) } + val cover = LocalChromeCover.current - // The app lays its map out around whatever the host covers, so a template change moves the - // visible area even though the surface itself hasn't changed. - LaunchedEffect(template, size) { - val (width, height) = size + // 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 + val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) } + LaunchedEffect(template, size, measured) { if (width > 0 && height > 0) { - val visible = visibleAreaFor(template, width, height) + val visible = visibleAreaFor(template, width, height, measured) connection.reportAreas(visible = visible, stable = visible) } } @@ -101,7 +110,9 @@ private fun AppSurface( // new one on it before releasing the old: the old display's disconnect then takes the new one's // connection with it and the map freezes for good. The nav bar hiding for the search keyboard // and the lyrics pane opening both resize this area. - androidx.compose.foundation.layout.BoxWithConstraints(modifier) { + androidx.compose.foundation.layout.BoxWithConstraints( + modifier.onGloballyPositioned { bounds = it.boundsInWindow() }, + ) { val epoch by connection.surfaceEpoch.collectAsState() androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) { SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize()) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt index 5551709..2627e34 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -2,6 +2,13 @@ package app.seb3thehacker.gearslip.car import app.seb3thehacker.gearslip.car.theme.* import android.graphics.Rect +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -10,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -86,11 +94,13 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) { * How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of * it. Expressed as the visible rectangle within a [width] x [height] surface. * + * Navigation's cards are measured where they land ([measured], from [ChromeCover]); an app + * centres its position marker in this rectangle, so a guess puts the marker under the card. * A template that isn't map-backed covers the surface completely, and reporting an empty * rectangle is how the app is told to stop bothering. */ -fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) { - is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt()) +fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) { + is NavigationTemplate -> measured ?: Rect(0, 0, width, height) is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, -> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height) @@ -98,77 +108,158 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (t else -> Rect(0, 0, 0, 0) } +/** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */ +internal enum class CoverEdge { TOP, BOTTOM, END } + +/** + * Where the chrome over the map actually landed, in window pixels. The map buttons count even + * while faded out, or whatever an app draws in a corner (Waze's speedometer) is under them the + * moment they come back. + */ +internal class ChromeCover { + private val pieces = mutableStateMapOf>() + + fun put(key: String, edge: CoverEdge, bounds: ComposeRect) { + val piece = edge to bounds + if (pieces[key] != piece) pieces[key] = piece + } + + fun remove(key: String) { + pieces.remove(key) + } + + /** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */ + fun visibleWithin(surface: ComposeRect, width: Int, height: Int): Rect { + var left = 0f + var top = 0f + var right = width.toFloat() + var bottom = height.toFloat() + pieces.values.forEach { (edge, b) -> + when (edge) { + CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top) + CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top) + CoverEdge.END -> right = minOf(right, b.left - surface.left) + } + } + // Never hand an app nothing to draw in, however the cards end up. + if (right - left < width / 4f) { left = 0f; right = width.toFloat() } + if (bottom - top < height / 4f) { top = 0f; bottom = height.toFloat() } + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + } +} + +internal val LocalChromeCover = compositionLocalOf { null } + +/** Records where this lands, padding included, until it leaves the screen. */ +@Composable +private fun Modifier.covers(key: String, edge: CoverEdge): Modifier { + val cover = LocalChromeCover.current ?: return this + DisposableEffect(cover, key) { onDispose { cover.remove(key) } } + return onGloballyPositioned { cover.put(key, edge, it.boundsInWindow()) } +} + // --- navigation ------------------------------------------------------------------------------ @Composable private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) { val pan by CarServices.nav.panMode.collectAsState() - Box(modifier.fillMaxWidth()) { - if (!pan) (template.navigationInfo as? RoutingInfo)?.let { - RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp)) + // Pan mode clears the map for looking around: only the buttons stay. + val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan } + val estimate = template.destinationTravelEstimate?.takeUnless { pan } + NavigationBars(template, routing, estimate, modifier) +} + +/** + * The turn in one short bar across the top, the arrival time in another along the bottom, so the + * map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The map buttons sit between them on the right. + */ +@Composable +private fun NavigationBars( + template: NavigationTemplate, + routing: RoutingInfo?, + estimate: TravelEstimate?, + modifier: Modifier, +) { + val pan by CarServices.nav.panMode.collectAsState() + Column(modifier.fillMaxSize()) { + routing?.let { + RoutingBar( + it, + Modifier.covers("nav-top", CoverEdge.TOP).padding(start = 12.dp, top = 12.dp, end = 12.dp) + .widthIn(max = NAV_BAR_MAX_WIDTH).fillMaxWidth(), + ) } - RightEdgeControls( - if (pan) null else template.actionStrip, - template.mapActionStrip, - Modifier.align(Alignment.TopEnd).padding(12.dp), - ) - if (!pan) template.destinationTravelEstimate?.let { - TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp)) + Row(Modifier.weight(1f).fillMaxWidth()) { + Box(Modifier.weight(1f).fillMaxHeight()) { + estimate?.let { + TravelEstimateBar( + it, + Modifier.align(Alignment.BottomStart).covers("nav-bottom", CoverEdge.BOTTOM).padding(12.dp) + .widthIn(max = NAV_BAR_MAX_WIDTH), + ) + } + } + RightEdgeControls( + if (pan) null else template.actionStrip, + template.mapActionStrip, + Modifier.padding(12.dp), + // Only the column: the app's own buttons are a wide row up top, over the map's + // edge rather than where a speedometer or the like would go. + mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END), + ) } } } -/** The manoeuvre card: what to do next, how far away it is, and onto what road. */ +/** The manoeuvre in one row: arrow, distance, what to do, onto what road, and lanes or the next turn. */ @Composable -private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { +private fun RoutingBar(info: RoutingInfo, modifier: Modifier = Modifier) { if (info.isLoading) { ChromeSurface(modifier) { - Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title) + Text("Finding a route…", Modifier.padding(horizontal = 16.dp, vertical = 12.dp), style = ChromeType.title) } return } val step = info.currentStep ?: return ChromeSurface(modifier) { - Column(Modifier.padding(14.dp)) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - CarGlyph(step.maneuver?.icon, Modifier.size(44.dp)) + CarGlyph(step.maneuver?.icon, Modifier.size(40.dp)) Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - val distance = info.currentDistance.display() - if (distance.isNotEmpty()) { - Text(distance, style = ChromeType.headline) - } - Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis) + val distance = info.currentDistance.display() + if (distance.isNotEmpty()) { + Text(distance, style = ChromeType.headline, maxLines = 1) + Spacer(Modifier.width(14.dp)) } - } - val road = step.road.text() - if (road.isNotEmpty()) { - Text( - road, - style = ChromeType.body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // Neither is sent by every app - most say only "turn left" and trust the driver to - // find the lane themselves - so both are drawn only when there is something to draw. - info.junctionImage?.let { junction -> - Spacer(Modifier.height(10.dp)) - JunctionImage(junction) - } - LaneGuidance(step, Modifier.padding(top = 10.dp)) - info.nextStep?.let { next -> - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(step.cue.text(), style = ChromeType.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + val road = step.road.text() + if (road.isNotEmpty()) { + Text( + road, + style = ChromeType.body, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + val next = info.nextStep + if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) { + LaneGuidance(step, Modifier.padding(start = 12.dp).widthIn(max = 200.dp).heightIn(max = 48.dp)) + } else if (next != null) { + Spacer(Modifier.width(12.dp)) Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.width(8.dp)) - CarGlyph(next.maneuver?.icon, Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + CarGlyph(next.maneuver?.icon, Modifier.size(24.dp)) } } + // Rare, and only where an arrow can't explain the junction: worth the bar growing for. + info.junctionImage?.let { junction -> + Spacer(Modifier.height(8.dp)) + JunctionImage(junction) + } } } } @@ -244,9 +335,8 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) { /** * The trip at a glance: time and distance left, the clock time it ends at, and - beside them, - * where a thumb reaches - the app's own way to stop navigating. A floating card rather than a - * full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip - * that happens to hold three numbers. The close button is Gearslip's own, not the app's: an + * where a thumb reaches - the app's own way to stop navigating. One short line along the bottom, + * stopping short of the map buttons. The close button is Gearslip's own, not the app's: an * actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the * first button here turned out to be a mute toggle, not an exit - so this leaves the app the way * going Home already does, rather than guess at the app's own controls and risk getting it wrong. @@ -265,7 +355,7 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod trip.takeIf { it.isNotEmpty() }, ).joinToString(" • ") - // The same solid card the manoeuvre card above it uses - one surface for the whole map + // The same solid card the manoeuvre bar above it uses - one surface for the whole map // chrome, rather than this one spot styled on its own as a bar over the map. ChromeSurface(modifier, shadowElevation = 8.dp) { val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() @@ -288,17 +378,23 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod shape = CircleShape, ) Spacer(Modifier.width(10.dp)) - Column { - if (time.isNotEmpty()) { - Text( - time, - style = ChromeType.headline, - color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface), - ) - } - if (detail.isNotEmpty()) { - Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant) - } + if (time.isNotEmpty()) { + Text( + time, + style = ChromeType.headline, + color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface), + maxLines = 1, + ) + } + if (detail.isNotEmpty()) { + Spacer(Modifier.width(16.dp)) + Text( + detail, + style = ChromeType.body, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } @@ -454,8 +550,9 @@ private fun ListPane( /** Fraction of the surface the content pane covers beside a map. */ private const val CONTENT_PANE_FRACTION = 0.42f -/** Fraction of the surface the travel-estimate bar covers along the bottom. */ -private const val ESTIMATE_FRACTION = 0.14f + +/** Past this the bars only spread the same words further apart, across a map left with no music beside it. */ +private val NAV_BAR_MAX_WIDTH = 460.dp /** Search, sign-in and the like are laid out for a whole column, so they keep one. */ private fun Template?.needsFullHeight() = 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 1111404..67579be 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -222,6 +222,8 @@ internal fun RightEdgeControls( actionStrip: ActionStrip?, mapActionStrip: ActionStrip?, modifier: Modifier = Modifier, + /** For the stack of map buttons alone, without the row of the app's own above it. */ + mapActionsModifier: Modifier = Modifier, ) { val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() } @@ -263,7 +265,15 @@ internal fun RightEdgeControls( actions.forEach { ActionButton(it) } } } - mapActions.forEach { ActionButton(it) } + if (mapActions.isNotEmpty()) { + Column( + mapActionsModifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + mapActions.forEach { ActionButton(it) } + } + } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index 0be82f8..dbca04d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -91,6 +91,9 @@ class CarAppConnection(private val context: Context) { private var navigationManager: INavigationManager? = null /** Main thread only, both of these - the handoff is a race between them either way round. */ private var surfaceCallback: ISurfaceCallback? = null + + /** Last visible and stable areas the UI reported, kept for the next surface. */ + private var areas: Pair? = null private var lent: Lent? = null private var binding: ServiceConnection? = null @@ -552,9 +555,11 @@ class CarAppConnection(private val context: Context) { // The app draws for the whole surface; the visible area is what isn't covered by host // chrome, and the stable area is the part that never gets covered. Both depend on the // template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it - // has measured. Until then the whole surface is fair game. + // has measured. A fresh surface the same size keeps the last ones, since the UI only + // reports again when they change; until then the whole surface is fair game. val whole = Rect(0, 0, surface.width, surface.height) - reportAreas(whole, whole) + val last = areas?.takeIf { (visible, stable) -> whole.contains(visible) && whole.contains(stable) } + reportAreas(last?.first ?: whole, last?.second ?: whole) GearslipLog.i( "host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface" @@ -575,6 +580,7 @@ class CarAppConnection(private val context: Context) { /** Tells the app which part of its surface the driver can actually see. */ fun reportAreas(visible: Rect, stable: Rect) { + areas = visible to stable val callback = surfaceCallback ?: return runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) } runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }