diff --git a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt index 81fc1f2..6b9a1d6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt +++ b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt @@ -53,8 +53,9 @@ object AirPlayPersistence { private const val KEY_FPS = "display_fps" private const val KEY_MEDIA_BUFFER_MS = "media_buffer_ms" private const val KEY_CLUSTER_MAP = "cluster_map_enabled" - private const val KEY_CLUSTER_MAP_LIFT = "cluster_map_lift_percent" - private const val KEY_CLUSTER_MAP_SHIFT = "cluster_map_shift_left_percent" + private const val KEY_CLUSTER_MAP_SCALE = "cluster_map_scale_percent" + private const val KEY_CLUSTER_MARKER_X = "cluster_marker_horizontal_step" + private const val KEY_CLUSTER_MARKER_Y = "cluster_marker_vertical_step" private const val KEY_WIDTH_PHYSICAL_MM = "display_width_physical_mm" private const val KEY_PHYSICAL_SIZE_BASIS = "display_physical_size_basis" private const val KEY_MAX_DETECTED_WIDTH = "display_max_detected_width" @@ -410,24 +411,32 @@ object AirPlayPersistence { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLUSTER_MAP, enabled).apply() } - fun loadClusterMapLiftPercent(context: Context): Int = CarPlayClusterDisplay.DEFAULT_LIFT_PERCENT.let { default -> - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MAP_LIFT, default) - .takeIf { it in CarPlayClusterDisplay.liftPresets } ?: default + fun loadClusterMapScalePercent(context: Context): Int = CarPlayClusterDisplay.STREAM_SCALE_PERCENT.let { default -> + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MAP_SCALE, default) + .takeIf { it in CarPlayClusterDisplay.scalePresets } ?: default } - fun saveClusterMapLiftPercent(context: Context, percent: Int) { - if (percent !in CarPlayClusterDisplay.liftPresets) return - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_CLUSTER_MAP_LIFT, percent).apply() + fun saveClusterMapScalePercent(context: Context, percent: Int) { + if (percent !in CarPlayClusterDisplay.scalePresets) return + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_CLUSTER_MAP_SCALE, percent).apply() } - fun loadClusterMapShiftPercent(context: Context): Int = CarPlayClusterDisplay.DEFAULT_SHIFT_PERCENT.let { default -> - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MAP_SHIFT, default) - .takeIf { it in CarPlayClusterDisplay.shiftPresets } ?: default + fun loadClusterMarkerHorizontalStep(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MARKER_X, 0) + .coerceIn(CarPlayClusterDisplay.horizontalSteps) + + fun saveClusterMarkerHorizontalStep(context: Context, step: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_CLUSTER_MARKER_X, step.coerceIn(CarPlayClusterDisplay.horizontalSteps)).apply() } - fun saveClusterMapShiftPercent(context: Context, percent: Int) { - if (percent !in CarPlayClusterDisplay.shiftPresets) return - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_CLUSTER_MAP_SHIFT, percent).apply() + fun loadClusterMarkerVerticalStep(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MARKER_Y, 0) + .coerceIn(CarPlayClusterDisplay.verticalSteps) + + fun saveClusterMarkerVerticalStep(context: Context, step: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_CLUSTER_MARKER_Y, step.coerceIn(CarPlayClusterDisplay.verticalSteps)).apply() } fun loadRightHandDrive(context: Context): Boolean = diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index 8c1bc5e..064ce24 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -687,9 +687,12 @@ class CarPlayHostActivity : ComponentActivity() { return CarPlayClusterDisplay.config( size.x, size.y, - AirPlayPersistence.loadClusterMapLiftPercent(this), - AirPlayPersistence.loadClusterMapShiftPercent(this), - ).also { appendLog("Cluster map: requesting ${size.x}x${size.y} cluster screen safeArea=${it.safeArea}") } + AirPlayPersistence.loadClusterMapScalePercent(this), + AirPlayPersistence.loadClusterMarkerHorizontalStep(this), + AirPlayPersistence.loadClusterMarkerVerticalStep(this), + ).also { + appendLog("Cluster map: requesting ${it.widthPixels}x${it.heightPixels} on ${size.x}x${size.y} safeArea=${it.safeArea}") + } } override fun onWindowFocusChanged(hasFocus: Boolean) { diff --git a/common/src/main/java/com/shilapi/xcertplay/DiLink51ClusterLayout.kt b/common/src/main/java/com/shilapi/xcertplay/DiLink51ClusterLayout.kt index 6175120..30c31a6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiLink51ClusterLayout.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiLink51ClusterLayout.kt @@ -27,7 +27,11 @@ internal object DiLink51ClusterLayout { } const val STREAM_WIDTH = 1920 const val STREAM_HEIGHT = 720 - fun streamConfig(): AirPlayDisplayConfig = CarPlayClusterDisplay.config(STREAM_WIDTH, STREAM_HEIGHT, 0, 0) + // DiLink 5.1 retains its measured native-size stream and bottom-centered crop. + // DiLink 5's marker safe area and scale controls must not alter this profile. + fun streamConfig(): AirPlayDisplayConfig = CarPlayClusterDisplay.config( + STREAM_WIDTH, STREAM_HEIGHT, scalePercent = 100, + ).copy(safeArea = null) fun supported(fingerprint: String = Build.FINGERPRINT): Boolean = fingerprint == FINGERPRINT fun theme(context: Context): Theme = Theme.entries.firstOrNull { diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index ade88cb..83de702 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -234,7 +234,7 @@ class DiPlayActivity : ComponentActivity() { "Shows the iPhone's cluster map on the instrument cluster. Choose Small or Full screen navi in the cluster's steering-wheel menu.", AirPlayPersistence.loadClusterMapEnabled(this)) { AirPlayPersistence.saveClusterMapEnabled(this, it) - reconnectForClusterMap(force = true) + reconnectForClusterMap() } if (DiLink51ClusterLayout.supported()) { val automatic = DiLink51ClusterLayout.automatic(this) @@ -270,19 +270,27 @@ class DiPlayActivity : ComponentActivity() { reconnectForClusterMap() } } else { - val lifts = CarPlayClusterDisplay.liftPresets - choice(card, "Car position on cluster map", listOf("Bottom", "Higher · default", "Much higher"), - lifts.indexOf(AirPlayPersistence.loadClusterMapLiftPercent(this)).coerceAtLeast(0)) { - AirPlayPersistence.saveClusterMapLiftPercent(this, lifts[it]) - reconnectForClusterMap() + val sizes = CarPlayClusterDisplay.scalePresets + choice(card, "Cluster map size", listOf("Standard · sharpest", "Larger · default", "Largest"), + sizes.indexOf(AirPlayPersistence.loadClusterMapScalePercent(this)).coerceAtLeast(0)) { + AirPlayPersistence.saveClusterMapScalePercent(this, sizes[it]) } - val shifts = CarPlayClusterDisplay.shiftPresets - choice(card, "Car position across cluster map", - listOf("Center", "Left · by 1/12", "Left · by 1/10", "Left · by 1/9 · default", "Left · by 1/8", "Left · by 1/6", "Far left · by 1/3"), - shifts.indexOf(AirPlayPersistence.loadClusterMapShiftPercent(this)).coerceAtLeast(0)) { - AirPlayPersistence.saveClusterMapShiftPercent(this, shifts[it]) - reconnectForClusterMap() + val across = CarPlayClusterDisplay.horizontalSteps.toList() + choice(card, "Car marker · horizontal", across.map { markerStepLabel(it, "Left", "Right") }, + across.indexOf(AirPlayPersistence.loadClusterMarkerHorizontalStep(this)).coerceAtLeast(0)) { + AirPlayPersistence.saveClusterMarkerHorizontalStep(this, across[it]) } + val upDown = CarPlayClusterDisplay.verticalSteps.toList() + choice(card, "Car marker · vertical", upDown.map { markerStepLabel(it, "Up", "Down") }, + upDown.indexOf(AirPlayPersistence.loadClusterMarkerVerticalStep(this)).coerceAtLeast(0)) { + AirPlayPersistence.saveClusterMarkerVerticalStep(this, upDown[it]) + } + card.addView(button("Reset car marker to centre", false) { + AirPlayPersistence.saveClusterMarkerHorizontalStep(this, 0) + AirPlayPersistence.saveClusterMarkerVerticalStep(this, 0) + render() + reconnectForClusterMap() + }, matchButton(10, 56)) } } } @@ -437,9 +445,17 @@ class DiPlayActivity : ComponentActivity() { } } - // The cluster screen is described at connection time, so a running session reconnects to apply. - private fun reconnectForClusterMap(force: Boolean = false) { - if ((force || AirPlayPersistence.loadClusterMapEnabled(this)) && CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this)) + // "Left 20 %", "Centre · default", "Down 10 %": a signed step reads as a direction and a distance. + private fun markerStepLabel(step: Int, negative: String, positive: String): String = when { + step == 0 -> "Centre · default" + step < 0 -> "$negative ${-step * CarPlayClusterDisplay.MARKER_STEP_PERCENT} %" + else -> "$positive ${step * CarPlayClusterDisplay.MARKER_STEP_PERCENT} %" + } + + // The cluster screen is described at connection time, so a running session reconnects over + // its current link. The position choices need no call: "Apply and reconnect" already does it. + private fun reconnectForClusterMap() { + if (CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this)) } private fun applyWirelessLink(mode: WirelessHotspotMode) { diff --git a/docs/BYD_NAVIGATION.md b/docs/BYD_NAVIGATION.md index e8e0eb3..6ed123a 100644 --- a/docs/BYD_NAVIGATION.md +++ b/docs/BYD_NAVIGATION.md @@ -21,7 +21,8 @@ Validated on DiLink5.0 / Android12, firmware `BYD-AUTO/DiLink5.0/DiLink5.0:12/SK - Turn on "CarPlay map on instrument cluster" under BYD navigation. The switch appears only when a cluster projection display is visible to the app. - In the cluster's own steering-wheel menu, choose "Full screen navi" or "Small screen navi". "Turn on by navi" shows the map only during stock BYD navigation, so it keeps showing the arrow output instead. - The cluster's speed readout stays visible. "Small screen navi" crops the same picture on the cluster side; Android does not report that crop. -- "Car position on cluster map" and "Car position across cluster map" set CarPlay's safe area, which moves the car marker. The defaults, Higher and 1/9 to the left, fit the tested cluster. +- The car marker is placed through CarPlay's safe area: the centre of the panel (x 35–64 %, y 16–75 %), measured with a calibration grid to be clear of BYD's own readouts in both Full and Small screen navi. "Car marker · horizontal" (Left 40 % … Right 40 %) and "Car marker · vertical" (Up 30 % … Down 30 %) move it from there in 10 % steps of the panel. Near a panel edge the safe area shrinks so the marker stays at its centre. +- "Cluster map size" sets the stream size, which the cluster scales up to the panel: Standard (100 %, sharpest), Larger (83 %, 1600x600, default) or Largest (67 %). Apple Maps ignores the reported physical size on the cluster, so resolution is the only way to change the map's scale. How it works: the iPhone lists the cluster content it offers in its `/info` request (`altScreenURLs`). DiPlay declares a second display of the cluster's size with no input devices and `initialURL=maps:/car/instrumentcluster/map`; without an initial URL the iPhone streams only a black frame. BYD exposes the cluster projection area as public presentation displays owned by `com.byd.containerservice`. The stock map's display (`fission_bg_XDJAScreenProjection`) is hidden from third-party apps, but its `shared_…_0` sibling is composited on top of it, so DiPlay shows a `Presentation` there. diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplay.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplay.kt index 1d0c134..89b5f1b 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplay.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplay.kt @@ -5,37 +5,86 @@ package com.shilapi.xcertplay.airplay * * The iPhone lists the cluster content it offers in its /info request (`altScreenURLs`); without * an initial URL it streams a black frame. The cluster has no input. Apple Maps keeps the car - * position inside the safe area while the map still fills the panel, so bottom and right insets - * move the car up and to the left, away from the cluster's own readouts. + * position inside the safe area while the map still fills the panel, so the safe area is set to + * the part of the panel the cluster leaves uncovered. */ object CarPlayClusterDisplay { const val MAP_URL = "maps:/car/instrumentcluster/map" - /** Bottom inset in percent of the height. The default suits the BYD cluster. */ - const val DEFAULT_LIFT_PERCENT = 25 - val liftPresets = listOf(0, DEFAULT_LIFT_PERCENT, 45) + /** + * Where the car marker goes, as percent of the panel (left, top, right, bottom). Measured on a + * DiLink 5.0 cluster with a calibration grid. In "Full screen navi" BYD draws a status row, + * turn/ADAS icons, the ADAS lane view (from x 68 %), speed and power readouts and a bottom band + * over the map; "Small screen navi" crops the panel to a centre window of about x 31–68 %, + * y 16–89 %. This centre area is clear in both modes, so no per-mode setting is needed. + */ + val SAFE_AREA_PERCENT = AirPlayInsets(top = 16, bottom = 25, left = 35, right = 36) - /** Right inset in percent of the width; the car moves left by half of it (22 % ≈ 1/9). */ - const val DEFAULT_SHIFT_PERCENT = 22 - val shiftPresets = listOf(0, 17, 20, DEFAULT_SHIFT_PERCENT, 25, 33, 67) + /** The driver can move the marker from that centre, in steps of 10 % of the panel. */ + const val MARKER_STEP_PERCENT = 10 + val horizontalSteps = -4..4 // negative = left + val verticalSteps = -3..3 // negative = up - private const val WIDTH_PHYSICAL_MM = 292 // a 12.3-inch 8:3 cluster panel + /** Where the car marker lands, in percent of the panel (x from the left, y from the top). */ + fun markerPercent(horizontalStep: Int, verticalStep: Int): Pair { + val area = SAFE_AREA_PERCENT + val x = (area.left + 100 - area.right) / 2.0 + horizontalStep.coerceIn(horizontalSteps) * MARKER_STEP_PERCENT + val y = (area.top + 100 - area.bottom) / 2.0 + verticalStep.coerceIn(verticalSteps) * MARKER_STEP_PERCENT + return x.coerceIn(MARKER_MARGIN, 100 - MARKER_MARGIN) to y.coerceIn(MARKER_MARGIN, 100 - MARKER_MARGIN) + } + + private const val MARKER_MARGIN = 5.0 + + /** + * Stream size in percent of the panel. The cluster scales the stream up, so a smaller stream + * gives a larger map: 83 % is clearly larger and still sharp; 50 % was visibly blurry. + */ + const val STREAM_SCALE_PERCENT = 83 + val scalePresets = listOf(100, STREAM_SCALE_PERCENT, 67) + + private const val WIDTH_PHYSICAL_MM = 292 // a 12.3-inch 8:3 cluster panel; Apple Maps ignores it here private const val FPS = 30 - fun config(widthPixels: Int, heightPixels: Int, liftPercent: Int, shiftPercent: Int): AirPlayDisplayConfig { - val lift = heightPixels * liftPercent.coerceIn(0, 90) / 100 - val shift = widthPixels * shiftPercent.coerceIn(0, 90) / 100 + fun config( + widthPixels: Int, + heightPixels: Int, + scalePercent: Int = STREAM_SCALE_PERCENT, + horizontalStep: Int = 0, + verticalStep: Int = 0, + ): AirPlayDisplayConfig { + // Height rounds to a multiple of 8 and width follows it, so the panel's aspect is kept + // (83 % of 1920x720 gives exactly 1600x600). The cluster scales the stream to the panel. + val scale = scalePercent.coerceIn(25, 100) + val height = roundTo8(heightPixels * scale / 100.0) + val width = roundTo8(height * widthPixels.toDouble() / heightPixels) return AirPlayDisplayConfig( - widthPixels = widthPixels, - heightPixels = heightPixels, + widthPixels = width, + heightPixels = height, widthPhysicalMm = WIDTH_PHYSICAL_MM, - heightPhysicalMm = Math.round(WIDTH_PHYSICAL_MM * heightPixels.toDouble() / widthPixels).toInt(), + heightPhysicalMm = Math.round(WIDTH_PHYSICAL_MM * height.toDouble() / width).toInt(), fps = FPS, primaryInputDevice = 0, features = 0, initialUrl = MAP_URL, - safeArea = if (lift > 0 || shift > 0) AirPlayInsets(bottom = lift, right = shift) else null, + safeArea = safeArea(width, height, horizontalStep, verticalStep), safeAreaDrawOutside = true, ) } + + // The safe area keeps its measured size around the marker and shrinks only where the marker + // comes close to a panel edge, so the marker always sits at its centre. + private fun safeArea(width: Int, height: Int, horizontalStep: Int, verticalStep: Int): AirPlayInsets { + val area = SAFE_AREA_PERCENT + val (x, y) = markerPercent(horizontalStep, verticalStep) + val halfWidth = minOf((100 - area.left - area.right) / 2.0, x, 100 - x) + val halfHeight = minOf((100 - area.top - area.bottom) / 2.0, y, 100 - y) + return AirPlayInsets( + top = (height * (y - halfHeight) / 100).toInt(), + bottom = (height * (100 - y - halfHeight) / 100).toInt(), + left = (width * (x - halfWidth) / 100).toInt(), + right = (width * (100 - x - halfWidth) / 100).toInt(), + ) + } + + private fun roundTo8(value: Double): Int = (Math.round(value / 8) * 8).toInt().coerceAtLeast(8) } diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplayTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplayTest.kt index 67ab776..a09a032 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplayTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayClusterDisplayTest.kt @@ -1,19 +1,12 @@ package com.shilapi.xcertplay.airplay import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class CarPlayClusterDisplayTest { @Test fun defaultClusterIsAdvertisedAsAnInputlessMapScreen() { - val cluster = CarPlayClusterDisplay.config( - 1920, - 720, - CarPlayClusterDisplay.DEFAULT_LIFT_PERCENT, - CarPlayClusterDisplay.DEFAULT_SHIFT_PERCENT, - ) val info = AirPlayInfoPlist.build( AirPlayConfig( deviceName = "test", @@ -21,7 +14,7 @@ class CarPlayClusterDisplayTest { btMac = "02:00:00:00:00:02", sourceVersion = "366.0", main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), - cluster = cluster, + cluster = CarPlayClusterDisplay.config(1920, 720), ), ) @@ -29,8 +22,9 @@ class CarPlayClusterDisplayTest { assertEquals(2, displays.size) val alt = displays[1] as Map<*, *> assertEquals(111, alt["type"]) - assertEquals(1920, alt["widthPixels"]) - assertEquals(720, alt["heightPixels"]) + // 83 % of the 1920x720 panel, same 8:3 aspect. + assertEquals(1600, alt["widthPixels"]) + assertEquals(600, alt["heightPixels"]) assertEquals(0, alt["features"]) assertEquals(0, alt["primaryInputDevice"]) assertEquals("maps:/car/instrumentcluster/map", alt["initialURL"]) @@ -38,14 +32,14 @@ class CarPlayClusterDisplayTest { assertEquals(110, alt["heightPhysical"]) val view = (alt["viewAreas"] as List<*>).single() as Map<*, *> - assertEquals(1920, view["widthPixels"]) - assertEquals(720, view["heightPixels"]) + assertEquals(1600, view["widthPixels"]) + assertEquals(600, view["heightPixels"]) val safe = view["safeArea"] as Map<*, *> - // 25 % off the bottom and 22 % off the right: the car sits higher and about 1/9 further left. - assertEquals(1498, safe["widthPixels"]) - assertEquals(540, safe["heightPixels"]) - assertEquals(0, safe["originXPixels"]) - assertEquals(0, safe["originYPixels"]) + // The centre area clear in both cluster navi modes: left 35 %, right 36 %, top 16 %, bottom 25 %. + assertEquals(560, safe["originXPixels"]) + assertEquals(96, safe["originYPixels"]) + assertEquals(464, safe["widthPixels"]) + assertEquals(354, safe["heightPixels"]) assertEquals(true, safe["drawUIOutsideSafeArea"]) // The main screen keeps its touch and knob features. @@ -53,22 +47,50 @@ class CarPlayClusterDisplayTest { } @Test - fun noOffsetsLeaveTheWholeClusterAsTheSafeArea() { - val cluster = CarPlayClusterDisplay.config(1920, 720, 0, 0) + fun scaledStreamsKeepThePanelAspect() { + val sizes = (CarPlayClusterDisplay.scalePresets + 50).map { + CarPlayClusterDisplay.config(1920, 720, scalePercent = it).let { c -> c.widthPixels to c.heightPixels } + } - assertNull(cluster.safeArea) + assertEquals(listOf(1920 to 720, 1600 to 600, 1280 to 480, 960 to 360), sizes) + assertTrue(CarPlayClusterDisplay.STREAM_SCALE_PERCENT in CarPlayClusterDisplay.scalePresets) } @Test - fun offsetsAreBoundedSoTheSafeAreaNeverCollapses() { - val cluster = CarPlayClusterDisplay.config(1000, 400, 150, -20) + fun markerStepsMoveTheWholeSafeArea() { + val centre = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100).safeArea!! + val farLeftHigher = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100, horizontalStep = -2, verticalStep = -1).safeArea!! - assertEquals(AirPlayInsets(bottom = 360, right = 0), cluster.safeArea) + // Two 10 % steps left (384 px) and one 10 % step up (72 px), same size as the centred area. + assertEquals(centre.left - 384, farLeftHigher.left) + assertEquals(centre.right + 384, farLeftHigher.right) + assertEquals(centre.top - 72, farLeftHigher.top) + assertEquals(centre.bottom + 72, farLeftHigher.bottom) } @Test - fun defaultsAreAmongTheOfferedChoices() { - assertTrue(CarPlayClusterDisplay.DEFAULT_LIFT_PERCENT in CarPlayClusterDisplay.liftPresets) - assertTrue(CarPlayClusterDisplay.DEFAULT_SHIFT_PERCENT in CarPlayClusterDisplay.shiftPresets) + fun markerStepsAreBounded() { + val beyond = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100, horizontalStep = 9, verticalStep = -9).safeArea + val limit = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100, horizontalStep = 4, verticalStep = -3).safeArea + + assertEquals(limit, beyond) + } + + @Test + fun nearAnEdgeTheSafeAreaShrinksAroundTheMarker() { + val top = CarPlayClusterDisplay.config(1000, 1000, scalePercent = 100, verticalStep = -3).safeArea!! + + // Marker at 15.5 % from the top: the area reaches the top edge and stays centred on it. + assertEquals(0, top.top) + assertEquals(310, 1000 - top.top - top.bottom) + assertEquals(15.5, CarPlayClusterDisplay.markerPercent(0, -3).second, 0.001) + } + + @Test + fun theCarSitsNearThePanelCentre() { + val safe = CarPlayClusterDisplay.SAFE_AREA_PERCENT + val centreX = (safe.left + 100 - safe.right) / 2.0 + + assertTrue("centre x $centreX", centreX in 48.0..52.0) } }