From a5f18d35cd694c13fe8d9720dfcd4123d065286c Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:06:03 -0400 Subject: [PATCH] Restore C4 MAX fade; contain SLC offsets for readability --- .../galaxy/tests/test_layout_preview.mjs | 6 + .../galaxy/tests/test_onroad_layout.mjs | 13 +- .../tests/test_speed_source_preference.mjs | 2 +- .../galaxy/web/js/onroad-layout-document.js | 2 +- .../system/android_auto/projection_layout.py | 6 +- .../starpilot/ui/large_speed_geometry.py | 6 +- .../starpilot/ui/onroad_compact_widgets.py | 23 +-- .../starpilot/ui/onroad_customization.py | 49 ++++--- openpilot/starpilot/ui/speed_limit_widget.py | 55 ++++++++ .../starpilot/ui/tests/test_dinish_speed.py | 88 +++++++++--- .../ui/tests/test_onroad_customization.py | 132 +++++++++++++----- .../ui/tests/test_onroad_display_holds.py | 8 +- .../ui/tests/test_onroad_navigation.py | 9 +- .../starpilot/ui/tests/test_onroad_parity.py | 13 +- .../ui/tests/test_runtime_snapshot.py | 2 +- .../starpilot/ui/tests/test_speed_stack.py | 6 +- .../ui/tests/test_unified_speed_native.py | 44 +++++- 17 files changed, 356 insertions(+), 108 deletions(-) diff --git a/openpilot/starpilot/galaxy/tests/test_layout_preview.mjs b/openpilot/starpilot/galaxy/tests/test_layout_preview.mjs index 4b8accf296..31cc61d598 100644 --- a/openpilot/starpilot/galaxy/tests/test_layout_preview.mjs +++ b/openpilot/starpilot/galaxy/tests/test_layout_preview.mjs @@ -10,6 +10,12 @@ assert.equal(sceneHidesWidget('slc_pending', 'compact', 'speed_limit', signLayou assert.equal(sceneHidesWidget('engaged', 'compact', 'speed_limit', signLayout, signWidgets), false) assert.equal(sceneHidesWidget('slc_pending', 'compact', 'speed_limit', signLayout, { ...signWidgets, speed_limit: { ...signWidgets.speed_limit, visualInsetTop: 0 } }), false) +const restoredLayout = { speed_limit: {x:330, y:20, enabled:true}, speed_limit_actions: {x:128, y:186, enabled:true} } +const restoredWidgets = { speed_limit: {width:118, height:132}, speed_limit_actions: {width:348, height:54} } +assert.equal(sceneHidesWidget('slc_pending', 'compact', 'speed_limit', restoredLayout, restoredWidgets), false) +assert.equal(sceneHidesWidget('slc_pending', 'compact', 'speed_limit', { + ...restoredLayout, speed_limit: {...restoredLayout.speed_limit, y:60} +}, restoredWidgets), true) function fixture() { const requests = [], updates = [], timers = new Map(), revoked = [] diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs index 13e3ff5767..60599369d7 100644 --- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs @@ -57,7 +57,7 @@ export function snapshot() { metadata.roadColorFields = [{ id: 'path', label: 'Path', default: '#30FF9CFF' }, { id: 'pathEdge', label: 'Path edges', default: '#00FF40FF' }, { id: 'laneLines', label: 'Lane lines', default: '#FFFFFFFF' }] metadata.profiles.compact.widgets.speed_limit_actions.visualInsetTop = 32 - const document = { version: 8, roadColors: { large: {}, compact: {} }, widgetColors: { large: {}, compact: {} }, palette: Object.fromEntries(metadata.paletteFields.map((field) => [field.id, field.default])), + const document = { version: 9, roadColors: { large: {}, compact: {} }, widgetColors: { large: {}, compact: {} }, palette: Object.fromEntries(metadata.paletteFields.map((field) => [field.id, field.default])), layouts: Object.fromEntries(Object.entries(metadata.profiles).map(([id, profile]) => [id, Object.fromEntries(Object.entries(profile.widgets).map(([key, definition]) => [key, { ...definition.default, ...(definition.resizable ? { size: definition.resizable.default } : {}) }]))])) } @@ -69,6 +69,17 @@ for (const name of ["layoutDocument", "previewDocument", "withDisplay", "ordered assert.equal(layoutModule[name], documentHelpers[name], name) const data = snapshot() +const restoredCompact = copy(data) +for (const [id, width, height, x, y] of [["max_speed", 162, 162, 0, 0], ["speed_limit", 118, 132, 330, 20], ["speed_limit_actions", 348, 54, 128, 186]]) { + Object.assign(restoredCompact.metadata.profiles.compact.widgets[id], { width, height, default: {x, y, enabled:true} }) + restoredCompact.document.layouts.compact[id] = {x, y, enabled:true} +} +delete restoredCompact.metadata.profiles.compact.widgets.speed_limit_actions.visualInsetTop +restoredCompact.defaults = copy(restoredCompact.document) +assert.equal(validSnapshot(restoredCompact), true, "Restored small-UI defaults are editable") +assert.equal(clampPlacement(restoredCompact.metadata.profiles.compact, "max_speed", 174, 25, restoredCompact.document.layouts.compact), null) +assert.deepEqual(clampPlacement(restoredCompact.metadata.profiles.compact, "speed_limit", 330, 20, restoredCompact.document.layouts.compact), {x:330, y:20}) +assert.deepEqual(restoredCompact.document.layouts.large, data.document.layouts.large) const withMode = copy(data) const modeId = "driving_mode_descriptions" withMode.metadata.profiles.compact.widgets[modeId] = { diff --git a/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs b/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs index 26765311aa..f29f37ec05 100644 --- a/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs +++ b/openpilot/starpilot/galaxy/tests/test_speed_source_preference.mjs @@ -3,7 +3,7 @@ import { validDocument } from "../web/js/onroad-layout.js" const metadata = { profiles: Object.fromEntries(["large", "compact"].map(name => [name, { bounds: {x:0,y:0,width:100,height:100}, widgets:{}, reservedZones:[] }])) } -const document = { version:8, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"}, +const document = { version:9, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"}, layouts:{large:{},compact:{}},widgetColors:{large:{},compact:{}},roadColors:{large:{},compact:{}} } assert.equal(validDocument(document, metadata), true) for (const flag of [true, false]) assert.equal(validDocument({...document,speedSources:flag},metadata),true) diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js index 63dc3ff12b..9d2749f49b 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js @@ -113,7 +113,7 @@ export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", " export function validDocument(document, metadata) { if (!object(document)) return false const profiles = metadata?.projection === true ? ["large"] : PROFILES - if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 8 || + if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== (metadata?.projection === true ? 8 : 9) || (Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") || (Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") || (Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) || diff --git a/openpilot/starpilot/system/android_auto/projection_layout.py b/openpilot/starpilot/system/android_auto/projection_layout.py index 75b065dc6d..e8aa630008 100644 --- a/openpilot/starpilot/system/android_auto/projection_layout.py +++ b/openpilot/starpilot/system/android_auto/projection_layout.py @@ -7,7 +7,7 @@ from pathlib import Path from openpilot.starpilot.system.android_auto.identity import DATA_DIR from openpilot.starpilot.system.android_auto.display_profile import screen_geometry from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, JETLINK_WIDGET, MODE_WIDGET, - STOPPED_TIMER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, MAP_OPACITY, NAV_CARD_SIZE, + STOPPED_TIMER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, NAV_CARD_SIZE, SPEED_ALIGNMENTS, customization_metadata, validate_map_box, validate_widget_order) from openpilot.starpilot.ui.onroad_stopped_timer import timer_size from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint @@ -204,7 +204,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): result = validate_layout_for_viewport(value, viewport, _legacy_geometry=True) card, actions = result['widgets']['cruise_limits'], result['widgets']['speed_limit_actions'] gap = actions['y'] - card['y'] - migrate_speed_layout(result['widgets'], 'large', metadata['bounds'], gap if gap in (372, 425) else 232) + migrate_speed_layout(result['widgets'], 'large', metadata['bounds'], gap if gap in (372, 425) else 232, widgets=metadata['widgets']) result['version'] = 2 return validate_layout_for_viewport(result, viewport) fields = {'version', 'canvas', 'widgets'} @@ -313,7 +313,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): if value['version'] < 4: card, actions = result['widgets']['cruise_limits'], result['widgets']['speed_limit_actions'] gap = actions['y'] - card['y'] - migrate_speed_layout(result['widgets'], 'large', bounds, gap if gap in (232, 492) else 232) + migrate_speed_layout(result['widgets'], 'large', bounds, gap if gap in (232, 492) else 232, widgets=metadata['widgets']) result['version'] = 4 if len(json.dumps(result, separators=(',', ':'), allow_nan=False).encode()) > MAX_BYTES: raise ValueError('Projection layout too large') diff --git a/openpilot/starpilot/ui/large_speed_geometry.py b/openpilot/starpilot/ui/large_speed_geometry.py index 6081f710c0..b0a9a43256 100644 --- a/openpilot/starpilot/ui/large_speed_geometry.py +++ b/openpilot/starpilot/ui/large_speed_geometry.py @@ -12,12 +12,12 @@ GAP = 8 SOURCE_ROW_HEIGHT = 76 PADDING = 24 -LIMIT_RECTS = {'large': (272, 94, 292, 124), 'compact': (158, 10, 264, 82)} +LIMIT_RECTS = {'large': (272, 94, 292, 124), 'compact': (330, 20, 118, 132)} ACTION_BOUNDS = { 'large': ((100, 248, 372, 408), (404, 248, 676, 408)), - 'compact': ((16, 118, 224, 222), (248, 118, 456, 222)), + 'compact': ((128, 182, 296, 240), (308, 182, 476, 240)), } -ACTION_TEXT = {'large': (64, 320), 'compact': (50, 146)} +ACTION_TEXT = {'large': (64, 320), 'compact': (48, 213)} def card_height(mode): diff --git a/openpilot/starpilot/ui/onroad_compact_widgets.py b/openpilot/starpilot/ui/onroad_compact_widgets.py index cb2b3256b8..3e4f54b473 100644 --- a/openpilot/starpilot/ui/onroad_compact_widgets.py +++ b/openpilot/starpilot/ui/onroad_compact_widgets.py @@ -18,7 +18,7 @@ from openpilot.starpilot.ui.onroad_conditional import reason as conditional_reas from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade -from openpilot.starpilot.ui.speed_limit_widget import MUTED, SpeedLimitWidget, draw_caption, draw_ink +from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget from openpilot.starpilot.ui.moon import draw_moon @@ -89,7 +89,7 @@ def draw_lead_icon(rect: rl.Rectangle) -> None: class CompactHudRenderer: - """Compact speed group and the native steering-wheel placement.""" + """Transient compact MAX, detached speed-limit sign and native steering wheel.""" def __init__(self, fonts: BitmapFonts, asset_directory: Path): self.fonts = fonts @@ -137,14 +137,18 @@ class CompactHudRenderer: cruise = "–" if state.cruise_kph is None else str(round(state.cruise_kph if state.metric else state.cruise_kph * 0.621371)) mx, my = widget_offset(state.customization, "compact", "max_speed") if set_speed_alpha > 0.01 and placement(state.customization, "compact", "max_speed")["enabled"]: + shadow = rl.Color(0, 0, 0, int(255 / 2 * set_speed_alpha)) + try: + rl.draw_circle_gradient(rl.Vector2(81 + mx, 81 + my), 81, shadow, rl.BLANK) + except (RuntimeError, TypeError): + rl.draw_circle_gradient(int(81 + mx), int(81 + my), 81, shadow, rl.BLANK) red, green, blue, alpha = rgba(state.customization, "text", "compact", "max_speed") - color = rl.Color(red, green, blue, int(alpha * set_speed_alpha)) - muted = rl.Color(MUTED.r, MUTED.g, MUTED.b, int(255 * set_speed_alpha)) - speed_size = 108 - while speed_size > 72 and self.fonts.measure(cruise, FontRole.SET_SPEED, speed_size).width > 124: + color = rl.Color(red, green, blue, int(alpha * 0.9 * set_speed_alpha)) + speed_size = 112 + while speed_size > 72 and self.fonts.measure(cruise, FontRole.SET_SPEED, speed_size).width > 128: speed_size -= 1 - draw_caption(self.fonts, 'SET', 23 + mx, 10 + my, muted, unit='km/h' if state.metric else 'mph') - draw_ink(self.fonts, cruise, FontRole.SET_SPEED, speed_size, 17 + mx, 34 + my, color) + self.fonts.draw(cruise, FontRole.SET_SPEED, speed_size, 17 + mx, -4 + my, color) + self.fonts.draw("MAX", FontRole.SEMI_BOLD, 36, 25 + mx, 109 + my, color) def render_steering_wheel(self, state: OnroadState) -> None: color = self._feedback.update(state.wheel_acceleration, state.appearance.wheel_pedal_feedback, state.observed_ns, state.drive_frame) @@ -186,7 +190,8 @@ class CompactHudRenderer: self._was_cruise_active = True self._last_cruise_display = display self._last_cruise_ns = now_ns - return self._set_speed_alpha.update(1.0) + elapsed = now_ns - self._set_speed_changed_ns if self._set_speed_changed_ns is not None else 0 + return self._set_speed_alpha.update(float(0 < elapsed < 2_500_000_000)) if (self._last_cruise_ns is None or now_ns < self._last_cruise_ns or now_ns - self._last_cruise_ns > 150_000_000): self._set_speed_alpha.x = 0.0 diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py index e568a77787..48cce67538 100644 --- a/openpilot/starpilot/ui/onroad_customization.py +++ b/openpilot/starpilot/ui/onroad_customization.py @@ -59,6 +59,8 @@ class Widget(TypedDict): sourcesExtent: NotRequired[dict[str, float]] box: NotRequired[dict[str, int]] opacity: NotRequired[dict[str, int]] + anchorShift: NotRequired[float] + expandable: NotRequired[bool] class LayoutProfile(TypedDict): @@ -95,23 +97,23 @@ PROFILES: dict[str, LayoutProfile] = { "protectedWidget": "speed_limit_actions", "reservedZones": [], "widgets": { - "max_speed": _widget("Set speed", "max_speed", 146, 114, 0, 0), - "speed_limit": _widget("Speed limit", "speed_limit", 264, 82, 158, 18), - "speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", 440, 52, 16, 122), + "max_speed": _widget("Set speed", "max_speed", 162, 162, 0, 0), + "speed_limit": _widget("Speed limit", "speed_limit", 118, 132, 330, 20), + "speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", 348, 54, 128, 186), "steering_wheel": _widget("Steering wheel", "steering_wheel", 50, 50, 21, 176), "driver_monitor": _widget("Driver monitoring", "driver_monitor", 60, 60, 16, 10)}}} -# The editor protects the visible words; padded touch targets extend to y=222. -PROFILES['compact']['widgets']['speed_limit']['visualInsetTop'] = 12 -PROFILES['compact']['widgets']['speed_limit']['visualInsetLeft'] = 8 -PROFILES['compact']['widgets']['speed_limit_actions']['note'] = 'Accept / Decline appear after tapping a pending limit; touch areas extend below the labels.' -PROFILES['compact']['widgets']['speed_limit_actions']['bounds'] = {'x': 0, 'y': 4, 'width': 476, 'height': 188} +PROFILES['compact']['widgets']['speed_limit_actions']['note'] = 'Accept / Decline appear after tapping a pending limit.' SPEED_LAYOUT_V7 = { 'large': {'cruise_limits': (320, 210, 88, 75), 'speed_limit_actions': (320, 108, 88, 307)}, 'compact': {'max_speed': (162, 162, 0, 0), 'speed_limit': (118, 132, 174, 12), 'speed_limit_actions': (282, 54, 174, 180)}, } +COMPACT_SPEED_LAYOUT_V8 = { + 'max_speed': (146, 114, 0, 0), 'speed_limit': (264, 82, 158, 18), + 'speed_limit_actions': (440, 52, 16, 122), +} for profile, sizes in DM_SIZES.items(): PROFILES[profile]['widgets']['driver_monitor']['default']['size'] = sizes[1] @@ -177,9 +179,9 @@ PROFILES["large"]["widgets"][NAV_CARD_WIDGET] = _nav_card _nav_map = _widget("Map overlay", NAV_MAP_WIDGET, 560, 420, 1230, 625) _nav_map["default"].update(enabled=False, width=560, height=420, opacity=MAP_OPACITY[2]) _nav_map["box"] = {"minWidth": MAP_MIN_SIZE[0], "maxWidth": 1800, "minHeight": MAP_MIN_SIZE[1], "maxHeight": 1020} -_nav_map["opacity"] = dict(zip(("min", "max", "default"), MAP_OPACITY)) +_nav_map["opacity"] = dict(zip(("min", "max", "default"), MAP_OPACITY, strict=True)) _nav_map["note"] = ("Road lines and your route over the camera. Drag the corner to resize; opacity sets how much shows. " - "Turning it on here turns it off in the other screen's layout.") + + "Turning it on here turns it off in the other screen's layout.") PROFILES["large"]["widgets"][NAV_MAP_WIDGET] = _nav_map JETLINK_WIDGET = "jetlink_status" @@ -187,7 +189,7 @@ JETLINK_SIZE = (420, 84) JETLINK_OPACITY = (0, 100, 85) # percent: min, max, default _jetlink = _widget("Jetlink status", JETLINK_WIDGET, *JETLINK_SIZE, 30, 650) _jetlink["default"].update(enabled=False, opacity=JETLINK_OPACITY[2]) -_jetlink["opacity"] = dict(zip(("min", "max", "default"), JETLINK_OPACITY)) +_jetlink["opacity"] = dict(zip(("min", "max", "default"), JETLINK_OPACITY, strict=True)) _jetlink["note"] = "Shown while Jetlink is on: connection, device and host processing time. Opacity sets the card background." PROFILES["large"]["widgets"][JETLINK_WIDGET] = _jetlink # Restore the Dom road graphic in the gap between the speed card and main speed. @@ -301,7 +303,7 @@ def customization_metadata(): def default_document(): - return {"version": 8, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, + return {"version": 9, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, "widgetColors": {profile: {} for profile in PROFILES}, "layouts": { profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})} for key, widget in data["widgets"].items()} @@ -316,17 +318,18 @@ def validate_map_box(placement, widget): raise ValueError("Invalid map overlay size or opacity") -def migrate_speed_layout(layout, profile, bounds, old_gap=232): +def migrate_speed_layout(layout, profile, bounds, old_gap=232, *, previous=None, widgets=None): + widgets = PROFILES[profile]['widgets'] if widgets is None else widgets if profile == 'large': card, actions = layout['cruise_limits'], layout['speed_limit_actions'] if actions['x'] == card['x'] and actions['y'] - card['y'] == old_gap: actions.update(x=card['x'] + ACTION_X - 88, y=card['y'] + ACTION_Y - 75) gauge = layout[CEM_GAUGE_WIDGET] if (gauge['x'], gauge['y']) == (480, 99): - default = PROFILES['large']['widgets'][CEM_GAUGE_WIDGET]['default'] + default = widgets[CEM_GAUGE_WIDGET]['default'] gauge.update(x=default['x'], y=default['y']) - for key, (_, _, old_x, old_y) in SPEED_LAYOUT_V7[profile].items(): - saved, widget = layout[key], PROFILES[profile]['widgets'][key] + for key, (_, _, old_x, old_y) in (previous or SPEED_LAYOUT_V7[profile]).items(): + saved, widget = layout[key], widgets[key] area = widget.get('bounds', bounds) if (saved['x'], saved['y']) == (old_x, old_y): saved.update(x=widget['default']['x'], y=widget['default']['y']) @@ -340,7 +343,7 @@ def migrate_speed_layout(layout, profile, bounds, old_gap=232): if above < 0 and below + height > bounds['height']: actions['y'] = PROFILES[profile]['widgets']['speed_limit_actions']['default']['y'] break - # The words now span the screen. Move only widgets intersecting that new row. + # Keep custom placements unless they intersect the confirmation words. for key, height in _compact_action_collisions(layout): candidates = (actions['y'] - height, actions['y'] + action_height) layout[key]['y'] = min((y for y in candidates if 0 <= y <= bounds['height'] - height), @@ -362,7 +365,7 @@ def _compact_action_collisions(layout): def validate_document(value): - if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 9): + if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 10): raise ValueError("Invalid customization version") fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set()) if value["version"] >= 3: @@ -416,6 +419,9 @@ def validate_document(value): "y": actions_default["y"] + cruise["y"] - cruise_default["y"], "enabled": cruise["enabled"], } + if value['version'] == 8 and 'speed_limit_actions' not in layouts['compact']: + _, _, x, y = COMPACT_SPEED_LAYOUT_V8['speed_limit_actions'] + layouts['compact']['speed_limit_actions'] = {'x': x, 'y': y, 'enabled': True} for profile in PROFILES: for key in ("driver_monitor", "torque_bar", "speed_limit_actions"): layouts[profile].setdefault(key, dict(PROFILES[profile]["widgets"][key]["default"])) @@ -530,6 +536,8 @@ def validate_document(value): dimensions = legacy.get(key) if value['version'] < 6 and profile == 'large' else None if dimensions is None and value['version'] < 8: dimensions = SPEED_LAYOUT_V7[profile].get(key) + if value['version'] == 8 and profile == 'compact': + dimensions = COMPACT_SPEED_LAYOUT_V8.get(key) dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else size if key in WIDGET_SIZES else placement[extent] if widget.get("box") else widget[extent]) if old_gauge: @@ -539,7 +547,7 @@ def validate_document(value): result["layouts"][profile][key] = {**placement, **({"size": size} if key in WIDGET_SIZES else {})} if old_gauge: result["layouts"][profile][key].update(x=placement["x"] + CEM_GAUGE_INSET[0], y=placement["y"] + CEM_GAUGE_INSET[1]) - if profile == "compact" and value['version'] >= 8 and any(_compact_action_collisions(result['layouts'][profile])): + if profile == "compact" and value['version'] >= 9 and any(_compact_action_collisions(result['layouts'][profile])): raise ValueError("Widget overlaps protected speed limit actions") if 'widgetOrder' in value: orders = value['widgetOrder'] @@ -569,6 +577,9 @@ def validate_document(value): for profile in PROFILES: migrate_speed_layout(result['layouts'][profile], profile, PROFILES[profile]['bounds'], old_gap) return validate_document(result) + if value['version'] == 8: + migrate_speed_layout(result['layouts']['compact'], 'compact', PROFILES['compact']['bounds'], previous=COMPACT_SPEED_LAYOUT_V8) + return validate_document(result) if len(json.dumps(result, separators=(",", ":")).encode()) > MAX_BYTES: raise ValueError("Customization too large") return result diff --git a/openpilot/starpilot/ui/speed_limit_widget.py b/openpilot/starpilot/ui/speed_limit_widget.py index 5681e0c45f..5202fb90e5 100644 --- a/openpilot/starpilot/ui/speed_limit_widget.py +++ b/openpilot/starpilot/ui/speed_limit_widget.py @@ -67,6 +67,9 @@ class SpeedLimitWidget: self.pulse.update(state, now, visible=bounds is not None) if bounds is None: return + if self.profile == Profile.COMPACT: + self._compact_sign(state, bounds, now) + return shown, obs = resolve_unified_speed(state), state.speed_limit pending = shown.pending and not obs.automatic_adoption_pending elapsed = max(0., now - obs.pending_since_ns / 1e9) @@ -101,3 +104,55 @@ class SpeedLimitWidget: left, top, right, bottom = x - 4, number_top - 3, x + width + 4, number_top + height + 3 draw_line(self.profile, rl.Vector2(left, top), rl.Vector2(right, bottom), 3 if large else 2.5, color) draw_line(self.profile, rl.Vector2(right, top), rl.Vector2(left, bottom), 3 if large else 2.5, color) + + def _compact_sign(self, state: OnroadState, bounds, now: float) -> None: + shown, obs = resolve_unified_speed(state), state.speed_limit + x, top = bounds[0] + 12, bounds[1] + 12 + pending = shown.pending and not obs.automatic_adoption_pending + elapsed = max(0., now - obs.pending_since_ns / 1e9) + hop = -6 * math.sin(math.pi * (elapsed % 1.5) / 1.5) ** 2 if pending else 0 + white = self.pulse.color(rl.Color(244, 247, 245, 255), now) + secondary = self.pulse.color(rl.Color(223, 232, 227, 255), now) + + def centered(text, role, size, ink_top, color): + left, right = self.fonts.horizontal_ink(text, role, size) + a, b = self.fonts.vertical_ink(text, role, size) + origin = x + 59 - (left + right) / 2 + self.fonts.draw(text, role, size, origin, ink_top - a, color) + return origin + left, ink_top, origin + right, ink_top + b - a + + if state.appearance.use_vienna_sign: + cx, cy = x + 59, top + 67 + hop + rl.draw_circle(int(cx), int(cy), 59, rl.WHITE) + rl.draw_ring(rl.Vector2(cx, cy), 51, 59, 0, 360, 64, self.pulse.color(rl.Color(201, 34, 49, 255), now)) + color = self.pulse.color(rl.Color(0, 0, 0, 255), now) + size = 58 if len(shown.posted_text) <= 2 else 48 + a, b = self.fonts.vertical_ink(shown.posted_text, FontRole.BOLD, size) + number_top = top + 36 + hop if shown.offset_text else cy - (b - a) / 2 + number = centered(shown.posted_text, FontRole.BOLD, size, number_top, color) + if shown.offset_text: + centered(shown.offset_text, FontRole.MEDIUM, 36, top + 81 + hop, color) + else: + rect = rl.Rectangle(x + 2, top + 2, 114, 128) + rl.draw_rectangle_rounded(rect, .1, 16, rl.Color(10, 17, 19, 230)) + rl.draw_rectangle_rounded_lines_ex(rect, .1, 16, 2, white) + for label, y in (('SPEED', top + 11), ('LIMIT', top + 27)): + centered(label, FontRole.SEMI_BOLD, 20, y, white) + number = centered(shown.posted_text, FontRole.BOLD, 58, top + (50 if shown.offset_text else 61) + hop, white) + if shown.offset_text: + centered(shown.offset_text, FontRole.MEDIUM, 36, top + 98 + hop, secondary) + if pending: + target = displayed_adjusted_limit_mps(obs) + if target is not None and state.speed_mps is not None and target != state.speed_mps: + ax, ay = ((x + 105, top + 49 + hop) if state.appearance.use_vienna_sign else (x + 108, top + 23)) + arrow_color = color if state.appearance.use_vienna_sign else white + direction = -1 if target > state.speed_mps else 1 + tip = rl.Vector2(ax, ay + direction * 6) + draw_line(self.profile, rl.Vector2(ax, ay - direction * 6), tip, 2, arrow_color) + for dx in (-3, 3): + draw_line(self.profile, tip, rl.Vector2(ax + dx, tip.y - direction * 4), 2, arrow_color) + if obs.driver_override_active: + color = rl.Color(197, 163, 255, 240) + left, top, right, bottom = number + draw_line(self.profile, rl.Vector2(left - 3, top - 2), rl.Vector2(right + 3, bottom + 2), 2.5, color) + draw_line(self.profile, rl.Vector2(right + 3, top - 2), rl.Vector2(left - 3, bottom + 2), 2.5, color) diff --git a/openpilot/starpilot/ui/tests/test_dinish_speed.py b/openpilot/starpilot/ui/tests/test_dinish_speed.py index a3ee9a797b..7278d91027 100644 --- a/openpilot/starpilot/ui/tests/test_dinish_speed.py +++ b/openpilot/starpilot/ui/tests/test_dinish_speed.py @@ -45,9 +45,15 @@ def test_approved_limit_offset_and_action_sizes_fit_actual_fonts(): large = profile == Profile.LARGE limit_size, offset_size = (70, 46) if large else (58, 36) for value, offset in (('65', '+5'), ('120', '+10'), ('130', '-10'), ('145', '-137')): - width = (fonts.measure(value, FontRole.BOLD, limit_size).width + - fonts.measure(offset, FontRole.MEDIUM, offset_size).width + (40 if large else 31)) + a, b = fonts.horizontal_ink(value, FontRole.BOLD, limit_size) + value_width = b - a + a, b = fonts.horizontal_ink(offset, FontRole.MEDIUM, offset_size) + offset_width = b - a + width = value_width + offset_width + 40 if large else max(value_width, offset_width) + 20 assert width <= LIMIT_RECTS[profile][2] + action_width = 272 if large else 168 + assert all(fonts.measure(label, FontRole.MEDIUM, ACTION_TEXT[profile][0]).width + 4 <= action_width + for label in ('Accept', 'Decline')) ppi = (2160**2 + 1080**2)**.5 / 6 if large else 300 for text, role, size, minimum in (('65', FontRole.BOLD, limit_size, 2.8), ('+5', FontRole.MEDIUM, offset_size, 2.0), @@ -191,13 +197,17 @@ def test_native_captions_offsets_and_bounce_fit_their_touch_targets(): hud._set_speed_alpha.x = 1. hud.render_max_speed(current) hud._speed_limit_sign(current) - ink = {call[0]: (call[3], call[4]+fonts.vertical_ink(call[0], call[1], call[2])[0], - call[3]+fonts.measure(call[0], call[1], call[2]).width, + ink = {call[0]: (call[3]+fonts.horizontal_ink(call[0], call[1], call[2])[0], call[4]+fonts.vertical_ink(call[0], call[1], call[2])[0], + call[3]+fonts.horizontal_ink(call[0], call[1], call[2])[1], call[4]+fonts.vertical_ink(call[0], call[1], call[2])[1]) for call in calls if call[-1].a == 255} - assert ink['SET'][1] == ink['LIMIT'][1] - assert ink['120'][3] == ink['+10'][3] - assert ink['120'][2] < ink['+10'][0] + if profile == Profile.LARGE: + assert ink['SET'][1] == ink['LIMIT'][1] + assert ink['120'][3] == ink['+10'][3] + assert ink['120'][2] < ink['+10'][0] + else: + assert 'SET' not in ink and 'SPEED' in ink + assert ink['120'][3] < ink['+10'][1] bounds = speed_limit_bounds(profile, current) assert bounds is not None left, top, right, bottom = bounds @@ -211,7 +221,7 @@ def test_native_captions_offsets_and_bounce_fit_their_touch_targets(): rl.close_window() -def test_compact_preview_sprite_keeps_the_caption_arrow_and_override_cross(): +def test_compact_preview_sprite_keeps_the_sign_offset_arrow_and_override_cross(): import json from unittest.mock import patch from openpilot.starpilot.ui.layout_preview_renderer import _Canvas, _straight_rgba, sample_state @@ -220,7 +230,7 @@ def test_compact_preview_sprite_keeps_the_caption_arrow_and_override_cross(): rl.init_window(128, 128, 'SLC preview crop fixture') canvas = _Canvas(Profile.COMPACT) try: - for y, scene in product((18, 0), ('slc_pending', 'slc_retained')): + for y, scene in product((20, 0), ('slc_pending', 'slc_retained')): document = default_document() document['layouts']['compact']['speed_limit']['y'] = y with patch.dict('openpilot.starpilot.ui.layout_preview_renderer.RUNTIME_PREVIEW_WIDGETS', compact=('speed_limit',)): @@ -229,7 +239,7 @@ def test_compact_preview_sprite_keeps_the_caption_arrow_and_override_cross(): slot = json.loads(sheet.info['starpilot-widget-art'])['sprites']['speed_limit'] sprite = sheet.crop((slot['x'], slot['y'], slot['x']+slot['width'], slot['y']+slot['height'])) rebuilt = Image.new('RGBA', Profile.COMPACT.size) - rebuilt.paste(sprite, (round(158+slot['offsetX']), round(y+slot['offsetY']))) + rebuilt.paste(sprite, (round(330+slot['offsetX']), round(y+slot['offsetY']))) image = rl.load_image_from_texture(canvas.target.texture) try: rl.image_flip_vertical(image) @@ -237,16 +247,60 @@ def test_compact_preview_sprite_keeps_the_caption_arrow_and_override_cross(): finally: rl.unload_image(image) assert np.array_equal(direct[..., 3], np.asarray(rebuilt)[..., 3]) - if y: - assert slot['offsetY'] < 0 and np.asarray(sprite)[:round(-slot['offsetY']), :, 3].any() - if scene == 'slc_retained': - assert slot['offsetX'] < 0 and np.asarray(sprite)[:, :round(-slot['offsetX']), 3].any() + assert slot['offsetX'] == slot['offsetY'] == 0 + assert (slot['width'], slot['height']) == (118, 132) finally: canvas.close() if owns_context: rl.close_window() +def test_compact_balanced_stack_uses_mock_sizes_and_contains_all_ink(): + from unittest.mock import patch + from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget + from openpilot.starpilot.ui.tests.test_unified_speed_native import state + owns_context = not rl.is_window_ready() + if owns_context: + rl.init_window(128, 128, 'C4 contained sign fixture') + try: + with BitmapFonts(Profile.COMPACT, default_font_directory()) as fonts: + widget = SpeedLimitWidget(fonts, Profile.COMPACT) + for vienna, (posted, offset), elapsed, (x, y) in product( + (False, True), ((65, 5), (120, 10), (130, -10), (145, -137), (55, 0)), + (0, 750_000_000, 1_500_000_000), ((330, 20), (100, 0), (358, 108))): + current = state(observed_ns=10_000_000_000+elapsed) + current.customization['layouts']['compact']['speed_limit'].update(x=x, y=y) + current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna), + speed_limit=replace(current.speed_limit, speed_limit_mps=posted/3.6, + pending_speed_limit_mps=posted/3.6, presentation_adjusted_limit_mps=(posted+offset)/3.6)) + calls = [] + with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)): + widget.render(current) + ink = [] + for text, role, size, left, top, _ in calls: + a, b = fonts.horizontal_ink(text, role, size) + c, d = fonts.vertical_ink(text, role, size) + box = left+a, top+c, left+b, top+d + if vienna: + hop = -6 if elapsed == 750_000_000 else 0 + cx, cy = x+59, y+67+hop + assert all((px-cx)**2+(py-cy)**2 < 59**2 for px, py in product((box[0], box[2]), (box[1], box[3]))) + else: + assert x+7 <= box[0] < box[2] <= x+111 + assert y+7 <= box[1] < box[3] <= y+125 + ink.append((text, box)) + if role == FontRole.BOLD: + assert size == (48 if vienna and len(text) > 2 else 58) + elif role == FontRole.MEDIUM: + assert size == 36 + for index, (_, a) in enumerate(ink): + for _, b in ink[index+1:]: + assert not (a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]) + finally: + if owns_context: + rl.close_window() + + def test_v5_geometry_migrates_and_keeps_custom_actions_independent(): doc = default_document() doc['version'] = 5 @@ -254,9 +308,9 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent(): doc['layouts']['large']['cruise_limits'].update(x=1550, y=638) doc['layouts']['large']['speed_limit_actions'].update(x=1550, y=954) migrated = validate_document(doc) - assert migrated['version'] == 8 - assert migrated['layouts']['large']['cruise_limits'] == {'x': 1342, 'y': 638, 'enabled': True} - assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1254, 'y': 890, 'enabled': True} + assert migrated['version'] == 9 + assert migrated['layouts']['large']['cruise_limits'] == {'x': 1550, 'y': 638, 'enabled': True} + assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 890, 'enabled': True} assert migrated['speedSources'] is True assert doc['layouts']['large']['cruise_limits']['x'] == 1550 assert validate_document(migrated) == migrated diff --git a/openpilot/starpilot/ui/tests/test_onroad_customization.py b/openpilot/starpilot/ui/tests/test_onroad_customization.py index 4c6a4abd85..fff21407b6 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_customization.py +++ b/openpilot/starpilot/ui/tests/test_onroad_customization.py @@ -65,19 +65,85 @@ def test_v7_speed_layout_migration_is_bounded_non_mutating_and_idempotent(): original = copy.deepcopy(document) migrated = validate_document(document) assert document == original - assert migrated['version'] == 8 and validate_document(migrated) == migrated - assert migrated['layouts']['large']['cruise_limits']['x'] == 1342 - assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1254, 'y': 890, 'enabled': True} + assert migrated['version'] == 9 and validate_document(migrated) == migrated + assert migrated['layouts']['large']['cruise_limits']['x'] == 1510 + assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 890, 'enabled': True} assert migrated['layouts']['large']['current_speed'] == original['layouts']['large']['current_speed'] assert migrated['layouts']['large']['cem_gauge'] == {'x': 56, 'y': 452, 'enabled': True} - assert migrated['layouts']['compact']['speed_limit']['x'] == 212 - assert migrated['layouts']['compact']['max_speed']['y'] == 8 + assert migrated['layouts']['compact']['speed_limit']['x'] == 330 + assert migrated['layouts']['compact']['max_speed']['y'] == 24 assert migrated['widgetColors'] == original['widgetColors'] and migrated['speedSources'] document['layouts']['large']['cruise_limits']['x'] = 2000 with pytest.raises(ValueError, match='outside'): validate_document(document) +@pytest.mark.parametrize('custom', (False, True)) +def test_v8_restores_only_compact_speed_defaults_and_preserves_customizations(custom): + import copy + from openpilot.starpilot.ui.onroad_customization import COMPACT_SPEED_LAYOUT_V8 + document = default_document() + document['version'] = 8 + for key, (_, _, x, y) in COMPACT_SPEED_LAYOUT_V8.items(): + document['layouts']['compact'][key].update(x=x, y=y) + if custom: + document['layouts']['compact']['speed_limit'].update(x=100, y=40, enabled=False) + document['layouts']['compact']['max_speed'].update(x=50, y=0) + document['widgetColors']['compact']['max_speed'] = {'text': '#12345678'} + document['layouts']['large']['cruise_limits'].update(x=1100, y=180) + document['widgetOrder'] = {profile: list(order) for profile, order in DEFAULT_WIDGET_ORDER.items()} + original = copy.deepcopy(document) + migrated = validate_document(document) + assert document == original + assert migrated['version'] == 9 and validate_document(migrated) == migrated + assert migrated['layouts']['large'] == document['layouts']['large'] + assert migrated['widgetColors'] == document['widgetColors'] + assert migrated['widgetOrder'] == document['widgetOrder'] + expected = document['layouts']['compact']['speed_limit'] if custom else {'x': 330, 'y': 20, 'enabled': True} + assert migrated['layouts']['compact']['speed_limit'] == expected + assert migrated['layouts']['compact']['max_speed']['x'] == (50 if custom else 0) + assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 128, 'y': 186, 'enabled': True} + + +def test_v8_compact_speed_widgets_at_old_edges_stay_in_bounds_after_upgrade(): + from openpilot.starpilot.ui.onroad_customization import COMPACT_SPEED_LAYOUT_V8 + document = default_document() + document['version'] = 8 + for key, (_, _, x, y) in COMPACT_SPEED_LAYOUT_V8.items(): + document['layouts']['compact'][key].update(x=x, y=y) + document['layouts']['compact']['max_speed'].update(x=330, y=126) + document['layouts']['compact']['speed_limit'].update(x=212, y=158) + migrated = validate_document(document) + assert migrated['layouts']['compact']['max_speed'] == {'x': 314, 'y': 24, 'enabled': True} + assert migrated['layouts']['compact']['speed_limit'] == {'x': 212, 'y': 108, 'enabled': True} + assert validate_document(migrated) == migrated + + +def test_v8_actionless_layout_preserves_customizations_when_adding_compact_actions(): + import copy + from openpilot.starpilot.ui.onroad_customization import COMPACT_SPEED_LAYOUT_V8, RAIL_WIDGETS + document = default_document() + document['version'] = 8 + for key, (_, _, x, y) in COMPACT_SPEED_LAYOUT_V8.items(): + document['layouts']['compact'][key].update(x=x, y=y) + large_actions = document['layouts']['large'].pop('speed_limit_actions') + for key in ('speed_limit_actions', *RAIL_WIDGETS): + del document['layouts']['compact'][key] + document['layouts']['large']['current_speed']['x'] = 700 + document['layouts']['compact']['speed_limit'].update(x=100, y=40) + document['palette']['text'] = '#ABCD1234' + document['widgetColors']['compact']['max_speed'] = {'text': '#12345678'} + original = copy.deepcopy(document) + migrated = validate_document(document) + assert document == original + assert migrated['version'] == 9 and validate_document(migrated) == migrated + assert migrated['layouts']['large'] == {**original['layouts']['large'], 'speed_limit_actions': large_actions} + assert migrated['layouts']['compact']['speed_limit'] == original['layouts']['compact']['speed_limit'] + assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 128, 'y': 186, 'enabled': True} + assert migrated['palette'] == original['palette'] + assert migrated['widgetColors'] == original['widgetColors'] + + def test_clock_is_an_opt_in_widget_and_migrates_saved_layout_and_layer_order(): document = default_document() metadata = customization_metadata()['profiles'] @@ -107,7 +173,7 @@ def test_clock_format_preference_defaults_migrates_and_validates(): validate_document(document) -@pytest.mark.parametrize("widget,x,y", [("max_speed", 174, 19), ("steering_wheel", 174, 131)]) +@pytest.mark.parametrize("widget,x,y", [("max_speed", 174, 25), ("steering_wheel", 174, 137)]) def test_protected_compact_actions_reject_actual_overlap(widget, x, y): document = default_document() document["layouts"]["compact"][widget].update(x=x, y=y) @@ -115,7 +181,7 @@ def test_protected_compact_actions_reject_actual_overlap(widget, x, y): validate_document(document) -@pytest.mark.parametrize("widget,x,y", [("speed_limit", 200, 48), ("max_speed", 174, 0), ("steering_wheel", 124, 174)]) +@pytest.mark.parametrize("widget,x,y", [("speed_limit", 200, 48), ("max_speed", 174, 0), ("steering_wheel", 78, 174)]) def test_protected_zone_edge_touch_is_valid(widget, x, y): document = default_document() document["layouts"]["compact"][widget].update(x=x, y=y) @@ -155,7 +221,7 @@ def test_mode_widget_defaults_off_without_resetting_saved_layouts(): key = 'driving_mode_descriptions' document = default_document() document['layouts']['large']['current_speed'].update(x=700, y=100, enabled=False) - document['layouts']['compact']['speed_limit_actions']['y'] = 114 + document['layouts']['compact']['speed_limit_actions']['y'] = 180 for layout in document['layouts'].values(): assert layout.pop(key)['enabled'] is False migrated = validate_document(document) @@ -166,7 +232,7 @@ def test_mode_widget_defaults_off_without_resetting_saved_layouts(): migrated['layouts']['large'][key].update(enabled=True, x=300, y=600) assert validate_document(migrated) == migrated migrated['layouts']['compact'][key]['enabled'] = True - migrated['layouts']['compact'][key]['y'] = 90 + migrated['layouts']['compact'][key]['y'] = 130 with pytest.raises(ValueError, match='protected speed limit actions'): validate_document(migrated) @@ -185,14 +251,14 @@ def test_v3_layout_migrates_wheel_size_without_changing_saved_placement_or_color del document["layouts"]["large"]["cem_gauge"] # Version 3 predates the CEM gauge. document["palette"]["text"] = "#ABCDEF80" document["layouts"]["large"]["steering_wheel"].update(x=1300, y=100, enabled=False) - document["layouts"]["compact"]["steering_wheel"].update(x=90, y=150) + document["layouts"]["compact"]["steering_wheel"].update(x=90, y=130) for profile in ("large", "compact"): del document["layouts"][profile]["steering_wheel"]["size"] migrated = validate_document(document) - assert migrated["version"] == 8 + assert migrated["version"] == 9 assert migrated["palette"] == document["palette"] assert migrated["layouts"]["large"]["steering_wheel"] == {"x": 1300, "y": 100, "enabled": False, "size": 192} - assert migrated["layouts"]["compact"]["steering_wheel"] == {"x": 90, "y": 174, "enabled": True, "size": 50} + assert migrated["layouts"]["compact"]["steering_wheel"] == {"x": 90, "y": 130, "enabled": True, "size": 50} @pytest.mark.parametrize("removed", [("driver_monitor", "torque_bar"), ("torque_bar",), ()]) @@ -238,7 +304,7 @@ def test_malformed_pre_action_cruise_placement_is_rejected(): def test_duplicate_key_cannot_apply_otherwise_valid_placement(): document = default_document() document["layouts"]["large"]["current_speed"]["x"] = 100 - raw = json.dumps(document).replace('"version": 8', '"version": 8, "version": 8').encode() + raw = json.dumps(document).replace('"version": 9', '"version": 9, "version": 9').encode() with patch("openpilot.starpilot.ui.onroad_customization.read_saved", return_value=(raw, True)): assert read_customization(Mock()) == default_document() @@ -370,7 +436,7 @@ def test_speed_limit_actions_move_independently_of_cruise_card_and_keep_touch_pr state = road(document, speed_limit=observation, longitudinal_active=True, slc_system_long_available=True) controls = slc_controls(Profile.LARGE, state) assert controls[0].bounds == (388, 600, 660, 760) - assert slc_controls(Profile.COMPACT, state)[0].bounds == (16, 118, 224, 222) + assert slc_controls(Profile.COMPACT, state)[0].bounds == (128, 182, 296, 240) emit = Mock() inputs = OnroadInput(emit) from openpilot.starpilot.ui.tests.test_slc_ui_actions import tap_sign @@ -392,24 +458,24 @@ def test_speed_limit_actions_move_independently_of_cruise_card_and_keep_touch_pr def test_moved_compact_actions_relocate_hitboxes_without_weakening_state_gate(): document = default_document() - document["layouts"]["compact"]["speed_limit_actions"].update(x=30, y=114) + document["layouts"]["compact"]["speed_limit_actions"].update(x=120, y=180) document["layouts"]["compact"]["speed_limit"]["enabled"] = False assert validate_document(document) == document - document["layouts"]["compact"]["speed_limit_actions"].update(x=36, y=114) + document["layouts"]["compact"]["speed_limit_actions"].update(x=128, y=180) document = validate_document(document) observation = SpeedLimitObservation(kind=ObservationKind.VALID, speed_limit_mps=20, pending_speed_limit_mps=15, session_id="drive", decision_id=1, presentation_id=2, action_enabled=True) state = road(document, speed_limit=observation, longitudinal_active=True, slc_system_long_available=True) - assert slc_controls(Profile.COMPACT, state)[0].bounds == (36, 110, 244, 214) + assert slc_controls(Profile.COMPACT, state)[0].bounds == (128, 176, 296, 234) emit = Mock() inputs = OnroadInput(emit, Profile.COMPACT) from openpilot.starpilot.ui.tests.test_slc_ui_actions import tap_sign document["layouts"]["compact"]["speed_limit"]["enabled"] = True tap_sign(inputs, state) document["layouts"]["compact"]["speed_limit"]["enabled"] = False - inputs.press(110, 200, state) - inputs.release(110, 200, state) + inputs.press(200, 200, state) + inputs.release(200, 200, state) assert emit.call_count == 1 inputs.press(480, 200, state) inputs.release(480, 200, state) @@ -450,10 +516,12 @@ def test_compact_us_sign_keeps_camera_visible_at_saved_position(speed, size): with patch.object(rl, "draw_rectangle_rounded") as fill, \ patch.object(rl, "draw_rectangle_rounded_lines_ex") as outline: hud._speed_limit_sign(state) - fill.assert_not_called() - outline.assert_not_called() - assert [(call.args[0], call.args[2]) for call in foreground_calls(fonts.draw)] == [("LIMIT", 24), ('km/h', 20), (str(speed), size)] - assert foreground_calls(fonts.draw)[0].args[3:5] == (200, 22) + fill.assert_called_once() + outline.assert_called_once() + rect = outline.call_args.args[0] + assert (rect.x, rect.y, rect.width, rect.height) == (202, 32, 114, 128) + assert [(call.args[0], call.args[2]) for call in foreground_calls(fonts.draw)] == [("SPEED", 20), ('LIMIT', 20), (str(speed), size)] + assert foreground_calls(fonts.draw)[0].args[3:5] == (254, 41) def test_compact_drag_keeps_animation_state_and_separate_origins(): @@ -468,7 +536,7 @@ def test_compact_drag_keeps_animation_state_and_separate_origins(): with patch.object(hud, "_speed_limit_sign"), patch.object(rl, "draw_circle_gradient"), patch.object(rl, "draw_texture_pro") as wheel: hud.render(state) hud.render(state) - assert foreground_calls(fonts.draw)[-1].args[3:5] == (117, 44) + assert foreground_calls(fonts.draw)[-1].args[3:5] == (125, 119) rect = wheel.call_args.args[2] assert (rect.x, rect.y) == (115, 175) alpha = hud._set_speed_alpha.x @@ -486,7 +554,7 @@ def test_saved_six_widget_layout_adds_only_default_rail_positions(): document = default_document() for key in ("model_confidence", "conditional_mode", "following_distance"): del document["layouts"]["compact"][key] - document["layouts"]["compact"]["speed_limit_actions"].update(x=30, enabled=False) + document["layouts"]["compact"]["speed_limit_actions"].update(x=120, enabled=False) document["palette"]["text"] = "#ABCD1234" migrated = validate_document(document) assert migrated["palette"] == document["palette"] @@ -513,7 +581,7 @@ def test_movable_rail_keeps_limits_and_does_not_cover_active_alerts(): widget._conditional.reset_mock() widget.render(rl.Rectangle(0, 0, 536, 240), road(document, alert=OnroadAlert(size=AlertSize.FULL))) widget._conditional.assert_not_called() - document["layouts"]["compact"]["conditional_mode"].update(x=200, y=101) + document["layouts"]["compact"]["conditional_mode"].update(x=200, y=107) with pytest.raises(ValueError, match="protected"): validate_document(document) document["layouts"]["compact"]["conditional_mode"].update(x=477, y=0) @@ -537,7 +605,7 @@ def test_v1_palette_positions_and_visibility_migrate_without_mutating_source(): original = copy.deepcopy(old) result = decode_document(json.dumps(old).encode()) assert old == original - assert result['version'] == 8 + assert result['version'] == 9 assert result['palette'] == old['palette'] for profile, layout in old['layouts'].items(): for key, position in layout.items(): @@ -682,9 +750,9 @@ def test_intentional_small_sign_action_overlap_roundtrips_and_large_is_independe from openpilot.starpilot.ui.onroad_state import compact_sign_obscured_by_actions document = default_document() large = json.loads(json.dumps(document["layouts"]["large"])) - document["layouts"]["compact"]["speed_limit"].update(x=200, y=108, enabled=enabled) + document["layouts"]["compact"]["speed_limit"].update(x=200, y=70, enabled=enabled) assert decode_document(json.dumps(validate_document(document))) == document - document["layouts"]["compact"]["speed_limit_actions"].update(x=16, y=114) + document["layouts"]["compact"]["speed_limit_actions"].update(x=120, y=180) assert validate_document(document) == document assert document["layouts"]["large"] == large observation = SpeedLimitObservation(kind=ObservationKind.VALID, speed_limit_mps=20, @@ -704,7 +772,7 @@ def test_saved_small_actions_near_top_have_no_heading_and_keep_labels_visible(mo from openpilot.starpilot.ui.onroad import OnroadView document = default_document() document["layouts"]["compact"]["speed_limit_actions"].update(x=16, y=4) - document["layouts"]["compact"]["max_speed"].update(y=100) + document["layouts"]["compact"]["max_speed"].update(x=314, y=74) document["layouts"]["compact"]["driver_monitor"].update(y=60) assert decode_document(json.dumps(document)) == document observation = SpeedLimitObservation(kind=ObservationKind.VALID, speed_limit_mps=20, @@ -720,13 +788,13 @@ def test_saved_small_actions_near_top_have_no_heading_and_keep_labels_visible(mo view._slc_actions(state) assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['Accept', 'Decline'] assert foreground_calls(view.fonts.draw)[0].args[4] >= 0 - assert slc_controls(Profile.COMPACT, state)[0].bounds == (16, 0, 224, 104) + assert slc_controls(Profile.COMPACT, state)[0].bounds == (16, 0, 184, 58) def test_overlapping_small_sign_restores_after_pending_and_actions_keep_exact_input(): from openpilot.starpilot.ui.onroad_state import SlcActionKind document = default_document() - document["layouts"]["compact"]["speed_limit"].update(y=108) + document["layouts"]["compact"]["speed_limit"].update(y=70) observation = SpeedLimitObservation(kind=ObservationKind.VALID, speed_limit_mps=20, pending_speed_limit_mps=15, session_id="drive", decision_id=1, presentation_id=2, action_enabled=True) diff --git a/openpilot/starpilot/ui/tests/test_onroad_display_holds.py b/openpilot/starpilot/ui/tests/test_onroad_display_holds.py index d30783ae48..3ffcde65be 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_display_holds.py +++ b/openpilot/starpilot/ui/tests/test_onroad_display_holds.py @@ -113,7 +113,7 @@ def test_slc_hold_rejects_pending_foreign_session_and_new_drive(): def test_compact_max_hides_during_gap_without_restarting_same_value_fade(): renderer = CompactHudRenderer(Mock(), Path('/unused')) active = OnroadState(True, True, 20.0, 80.0, SpeedLimitObservation(), longitudinal_active=True) - assert renderer._set_speed_opacity(active, 1_000_000_000) > 0.0 + assert renderer._set_speed_opacity(active, 1_000_000_000) == 0.0 assert renderer._set_speed_opacity(active, 1_020_000_000) > 0.0 before = renderer._set_speed_alpha.x unavailable = replace(active, cruise_kph=None) @@ -131,9 +131,9 @@ def test_compact_max_expires_once_and_short_gap_does_not_rearm_it(): active = OnroadState(True, True, 20.0, 80.0, SpeedLimitObservation(), longitudinal_active=True) for frame in range(200): renderer._set_speed_opacity(active, 1_000_000_000 + frame * 20_000_000) - assert renderer._set_speed_alpha.x > .99 + assert renderer._set_speed_alpha.x < .01 renderer._set_speed_opacity(replace(active, cruise_kph=None), 5_000_000_000) - assert renderer._set_speed_opacity(active, 5_040_000_000) > .99 + assert renderer._set_speed_opacity(active, 5_040_000_000) < .01 assert renderer._set_speed_changed_ns == 1_000_000_000 renderer._set_speed_opacity(replace(active, cruise_kph=90.0), 5_060_000_000) assert renderer._set_speed_opacity(replace(active, cruise_kph=90.0), 5_080_000_000) > .01 @@ -145,7 +145,7 @@ def test_new_drive_rearms_max_without_an_intervening_render(): renderer._set_speed_opacity(active, 1_000_000_000) renderer._set_speed_opacity(active, 1_020_000_000) new_drive = replace(active, drive_frame=200) - assert renderer._set_speed_opacity(new_drive, 9_000_000_000) > 0 + assert renderer._set_speed_opacity(new_drive, 9_000_000_000) == 0 assert renderer._set_speed_changed_ns == 9_000_000_000 assert renderer._set_speed_opacity(new_drive, 9_020_000_000) > 0 diff --git a/openpilot/starpilot/ui/tests/test_onroad_navigation.py b/openpilot/starpilot/ui/tests/test_onroad_navigation.py index bb922f891a..af18c833f1 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_navigation.py +++ b/openpilot/starpilot/ui/tests/test_onroad_navigation.py @@ -117,9 +117,12 @@ def test_slc_decision_keeps_its_touch_priority_only_where_it_overlaps(): card = NavigationCard(Mock(profile=Profile.COMPACT)) pending = SpeedLimitObservation(kind=ObservationKind.VALID, source='map', speed_limit_mps=22., pending_speed_limit_mps=22., session_id='s', decision_id=1, presentation_id=1, action_enabled=True) - # Open confirmation controls cover the card's lower edge. - assert card.bounds(replace(state(), slc_prompt_open=True, longitudinal_active=True, speed_limit=pending)) is None - assert card.bounds(replace(state(), slc_prompt_open=True, longitudinal_active=False, speed_limit=pending)) is None + observed = replace(state(), slc_prompt_open=True, longitudinal_active=True, speed_limit=pending) + assert card.bounds(observed) is not None + observed.customization['layouts']['compact']['speed_limit_actions']['y'] = 4 + observed.customization['layouts']['compact']['max_speed']['y'] = 58 + assert card.bounds(observed) is None + assert card.bounds(replace(observed, longitudinal_active=False)) is None # SLC being on, with no decision on screen, leaves the directions up. assert card.bounds(replace(state(), longitudinal_active=True, speed_limit=SpeedLimitObservation(action_enabled=True))) is not None large = NavigationCard(Mock(profile=Profile.LARGE)) diff --git a/openpilot/starpilot/ui/tests/test_onroad_parity.py b/openpilot/starpilot/ui/tests/test_onroad_parity.py index 7f9e50789b..a2b6635f96 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_parity.py +++ b/openpilot/starpilot/ui/tests/test_onroad_parity.py @@ -180,14 +180,15 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt(): fonts = Mock(profile=Profile.COMPACT) fonts.measure.return_value = NS(width=30, height=20) fonts.vertical_ink.return_value = (0, 20) + fonts.horizontal_ink.return_value = (0, 30) renderer = CompactHudRenderer(fonts, Mock()) valid = SpeedLimitObservation(kind=ObservationKind.VALID, source='vision', speed_limit_mps=24.6) shown = replace(road(), appearance=replace(road().appearance, show_speed_limit_sign=True), speed_limit=valid) with patch.object(rl, 'draw_rectangle_rounded') as fill, patch.object(rl, 'draw_rectangle_rounded_lines_ex') as outline: renderer._speed_limit_sign(shown) - fill.assert_not_called() - outline.assert_not_called() - assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['LIMIT', 'mph', '55'] + fill.assert_called_once() + outline.assert_called_once() + assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['SPEED', 'LIMIT', '55'] outline.reset_mock() renderer._speed_limit_sign(replace(shown, speed_limit=SpeedLimitObservation(kind=ObservationKind.STALE))) outline.assert_not_called() @@ -195,7 +196,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt(): outline.assert_not_called() with patch.object(rl, 'draw_circle') as circle, patch.object(rl, 'draw_ring'): renderer._speed_limit_sign(replace(shown, appearance=replace(shown.appearance, use_vienna_sign=True))) - circle.assert_not_called() + circle.assert_called_once() def test_pending_confirmation_omits_redundant_speed_heading(): @@ -214,7 +215,7 @@ def test_pending_confirmation_omits_redundant_speed_heading(): def test_compact_pending_label_and_button_follow_saved_action_placement(): document = default_document() - document['layouts']['compact']['speed_limit_actions']['x'] = 36 + document['layouts']['compact']['speed_limit_actions']['x'] = 120 observation = SpeedLimitObservation(kind=ObservationKind.VALID, speed_limit_mps=20, pending_speed_limit_mps=15, session_id='drive', decision_id=4, presentation_id=5, action_enabled=True) @@ -226,7 +227,7 @@ def test_compact_pending_label_and_button_follow_saved_action_placement(): view.fonts.vertical_ink.return_value = (0, 20) with patch.object(rl, 'draw_rectangle_rounded') as button, patch.object(rl, 'draw_line_ex'): view._slc_actions(state) - assert foreground_calls(view.fonts.draw)[0].args[3:5] == (115, 136) + assert foreground_calls(view.fonts.draw)[0].args[3:5] == (179, 203) button.assert_not_called() diff --git a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py index 581ab6ed3a..d8da13e437 100644 --- a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py +++ b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py @@ -814,7 +814,7 @@ class TestRuntimeSnapshot(unittest.TestCase): began = hud._set_speed_changed_ns self.assertEqual(hud._set_speed_changed_ns, began) if frame > 100: - self.assertGreater(opacity, .99) + self.assertLess(opacity, .01) ui.started = False ended = adapter.build(ShellMode.HOME, now_ns=now).onroad self.assertFalse(ended.camera_available or ended.engaged) diff --git a/openpilot/starpilot/ui/tests/test_speed_stack.py b/openpilot/starpilot/ui/tests/test_speed_stack.py index 47b37d0201..94ff2ca0ff 100644 --- a/openpilot/starpilot/ui/tests/test_speed_stack.py +++ b/openpilot/starpilot/ui/tests/test_speed_stack.py @@ -52,9 +52,9 @@ def test_v4_migration_preserves_settings_and_clamps_only_enlarged_widgets(): doc['layouts']['large']['speed_limit_actions'].update(x=1654, y=500) old_speed = dict(doc['layouts']['large']['current_speed']) migrated = validate_document(doc) - assert migrated['version'] == 8 - assert migrated['layouts']['large']['cruise_limits']['x'] == 1342 - assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1254, 'y': 248, 'enabled': True} + assert migrated['version'] == 9 + assert migrated['layouts']['large']['cruise_limits']['x'] == 1642 + assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1554, 'y': 248, 'enabled': True} assert migrated['layouts']['large']['current_speed'] == old_speed assert migrated['speedSources'] is True assert rgba(migrated, 'cardFill', 'large', 'cruise_limits') == (18, 52, 86, 120) diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_native.py b/openpilot/starpilot/ui/tests/test_unified_speed_native.py index 106d616dfa..967401b723 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_native.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_native.py @@ -19,7 +19,7 @@ from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mp @pytest.fixture(autouse=True) def drawing(monkeypatch): calls = {} - for name in ('draw_rectangle_rounded_lines_ex', 'draw_rectangle_rounded', 'draw_line_ex', 'draw_circle', 'draw_ring'): + for name in ('draw_rectangle_rounded_lines_ex', 'draw_rectangle_rounded', 'draw_line_ex', 'draw_circle', 'draw_ring', 'draw_circle_gradient'): calls[name] = Mock() monkeypatch.setattr(rl, name, calls[name]) monkeypatch.setattr('openpilot.starpilot.ui.onroad_large_widgets.draw_control_card', Mock()) @@ -38,6 +38,7 @@ def state(**changes): def fonts(profile=Profile.LARGE): return SimpleNamespace(profile=profile, draw=Mock(), vertical_ink=lambda text, role, size: (0, size * .75), + horizontal_ink=lambda text, role, size: (0, len(text) * size * .5), measure=lambda text, role, size: SimpleNamespace(width=len(text) * size * .5, height=size)) @@ -67,6 +68,21 @@ def test_both_renderers_show_small_latest_sign_and_offset(profile): assert '50' in texts and '+5' in texts and '40' not in texts +@pytest.mark.parametrize('metric', [False, True]) +@pytest.mark.parametrize('vienna', [False, True]) +@pytest.mark.parametrize('offset', [None, 0., .01, -.01]) +def test_compact_sign_omits_missing_zero_and_rounded_zero_offsets(metric, vienna, offset): + current = state(metric=metric) + factor = 3.6 if metric else 2.2369362921 + current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna), + speed_limit=SpeedLimitObservation(ObservationKind.VALID, 'map', 65 / factor, offset_mps=offset, + action_enabled=True)) + assert resolve_unified_speed(current).offset_text is None + font = fonts(Profile.COMPACT) + CompactHudRenderer(font, Mock())._speed_limit_sign(current) + assert [call.args[0] for call in font.draw.call_args_list] == (['65'] if vienna else ['SPEED', 'LIMIT', '65']) + + @pytest.mark.parametrize('profile', list(Profile)) def test_pending_sign_hops_and_arrow_follows_vehicle_speed(profile, drawing): font = fonts(profile) @@ -96,17 +112,35 @@ def test_only_set_speed_override_draws_cross(profile, drawing): assert len(foreground_calls(drawing['draw_line_ex'])) == 2 -def test_compact_set_speed_stays_visible_while_cruise_is_active(): +def test_compact_set_speed_fades_after_adjustment_and_reappears_on_change(): renderer = CompactHudRenderer(fonts(), Mock()) current = state(drive_frame=1) for now in range(10_000_000_000, 20_000_000_000, 100_000_000): alpha = renderer._set_speed_opacity(current, now) - assert alpha > .99 + assert alpha < .01 + changed = replace(current, cruise_kph=current.cruise_kph + 10) + assert renderer._set_speed_opacity(changed, 20_000_000_000) < .01 + assert renderer._set_speed_opacity(changed, 20_020_000_000) > .01 assert renderer._set_speed_opacity(replace(current, longitudinal_active=False), 20_000_000_000) == 0 -@pytest.mark.parametrize('profile', list(Profile)) -def test_limit_keeps_a_unit_when_set_speed_is_hidden(profile): +def test_compact_max_restores_dinish_number_label_and_shadow(drawing): + from openpilot.starpilot.ui.presentation import FontRole, font_filename + font = fonts(Profile.COMPACT) + hud = CompactHudRenderer(font, Mock()) + current = state(drive_frame=1, cruise_kph=80) + hud.render_max_speed(current) + hud.render_max_speed(replace(current, observed_ns=current.observed_ns + 20_000_000)) + assert [(call.args[0], call.args[1]) for call in font.draw.call_args_list] == [ + ('80', FontRole.SET_SPEED), ('MAX', FontRole.SEMI_BOLD)] + assert font_filename(Profile.COMPACT, FontRole.SET_SPEED) == 'DINish-Speed.fnt' + assert font.draw.call_args_list[0].args[3:5] == (17, -4) + assert font.draw.call_args_list[1].args[2:5] == (36, 25, 109) + drawing['draw_circle_gradient'].assert_called_once() + + +def test_large_limit_keeps_a_unit_when_set_speed_is_hidden(): + profile = Profile.LARGE current = state() current.customization['showMaxSpeed'] = False current.customization['layouts']['compact']['max_speed']['enabled'] = False