From 9bcc7bdd2d26f97161a59fc094fab2cf0a0a1f72 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:49:52 -0500 Subject: [PATCH] Align speed HUD for clearer SLC prompts --- .../tests/test_native_navigation_widgets.mjs | 4 +- .../galaxy/tests/test_onroad_layout.mjs | 8 +- .../galaxy/web/js/onroad-layout-document.js | 2 +- .../starpilot/ui/large_speed_geometry.py | 4 +- .../starpilot/ui/onroad_compact_widgets.py | 15 +- .../starpilot/ui/onroad_customization.py | 11 +- .../starpilot/ui/onroad_large_widgets.py | 33 +--- openpilot/starpilot/ui/speed_limit_widget.py | 144 +++++++++-------- .../starpilot/ui/tests/test_dinish_speed.py | 151 ++++++++++++------ .../ui/tests/test_onroad_customization.py | 51 ++++-- .../starpilot/ui/tests/test_onroad_parity.py | 4 +- .../ui/tests/test_slc_confirmation.py | 24 ++- .../ui/tests/test_slc_hud_contract.py | 10 +- .../starpilot/ui/tests/test_speed_stack.py | 2 +- .../ui/tests/test_unified_speed_native.py | 47 +++--- 15 files changed, 308 insertions(+), 202 deletions(-) diff --git a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs index e8f991c135..b911fbb5f5 100644 --- a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs +++ b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs @@ -10,11 +10,11 @@ print(json.dumps(dict(document=default_document(), defaults=default_document(), revision="native", editable=True, valid=True, activeProfile="large", supportedProfiles=["large"]))) `], { encoding: "utf8" })) assert.equal(validSnapshot(data), true) -for (const version of [11, 12, 13]) { +for (const version of [11, 12, 13, 14]) { assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), true) assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), true) } -for (const version of [9, 10, 14]) { +for (const version of [9, 10, 15]) { assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), false) assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), false) } diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs index 17db3af6e7..e2ef5b50de 100644 --- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs @@ -1220,15 +1220,17 @@ console.log('Display options, target switching and reload during polling passed' assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 500, sample.document.layouts.large), true) } -// Portrait sizing and its attached chooser use the same compact bounds as native input. +// Reserve the taller notice while keeping the opened buttons at their existing size. { const sample = snapshot(), profile = sample.metadata.profiles.compact profile.widgets.max_speed.width = 146 profile.widgets.speed_limit = { ...profile.widgets.speed_limit, width: 128, height: 144, - confirmation: { x: -172, y: -8, width: 328, height: 152 } } - sample.document.version = 13 + confirmation: { x: -172, y: -8, width: 328, height: 159 } } + sample.document.version = 14 sample.document.layouts.compact.speed_limit = { x: 320, y: 16, enabled: true } assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 16, sample.document.layouts.compact), false) + assert.equal(validDocument(sample.document, sample.metadata), true) + assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 20, sample.document.layouts.compact), true) assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 24, sample.document.layouts.compact), true) assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 317, 16, sample.document.layouts.compact), true) assert.equal(clampPlacement(profile, 'speed_limit', 330, 16, sample.document.layouts.compact).x, 320) diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js index 812d2b7f4e..ab4ce2547a 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js @@ -140,7 +140,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"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "reduceMotion") ? ["reduceMotion"] : []), ...(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"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12, 13].includes(document.version)) || + 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"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "reduceMotion") ? ["reduceMotion"] : []), ...(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"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12, 13, 14].includes(document.version)) || (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/ui/large_speed_geometry.py b/openpilot/starpilot/ui/large_speed_geometry.py index fe53aa512f..d77c3a3c9b 100644 --- a/openpilot/starpilot/ui/large_speed_geometry.py +++ b/openpilot/starpilot/ui/large_speed_geometry.py @@ -38,7 +38,7 @@ def confirmation_bounds(profile, x, y, *, opened=False): width = 2 * CONFIRMATION_PADDING + 2 * CONFIRMATION_BUTTON[profile] + CONFIRMATION_GAP if opened else 240 left = x + 172 if profile == 'large' else x - 172 if opened else x - 100 top = y - (12 if profile == 'large' else 8) - height = CONFIRMATION_HEIGHT[profile] if opened else 152 + height = CONFIRMATION_HEIGHT[profile] if opened else (168 if profile == 'large' else 159) return left, top, left + width * scale, top + height * scale @@ -52,6 +52,8 @@ def confirmation_buttons(profile, bounds): def confirmation_footprint(profile): left, top, right, bottom = confirmation_bounds(profile, 0, 0, opened=True) + # The notice is taller than the open buttons on Compact. + bottom = max(bottom, confirmation_bounds(profile, 0, 0)[3]) return {'x': left, 'y': top, 'width': right - left, 'height': bottom - top} diff --git a/openpilot/starpilot/ui/onroad_compact_widgets.py b/openpilot/starpilot/ui/onroad_compact_widgets.py index 67f3b869d0..0107415c59 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 SpeedLimitWidget, draw_caption, draw_ink, set_speed_status, speed_status +from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget, draw_set_speed, draw_set_status, set_speed_status, speed_status from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed from openpilot.starpilot.ui.moon import draw_moon from openpilot.starpilot.ui.onroad_personality import PersonalityWidget @@ -139,24 +139,15 @@ class CompactHudRenderer: status = set_speed_status(state, Profile.COMPACT, shown) persistent = shown.slc_target_text is not None or shown.override_text is not None or speed_status(state, Profile.COMPACT, shown) is not None set_speed_alpha = self._set_speed_opacity(state, (state.observed_ns or time.monotonic_ns()), slc_active=persistent) - cruise = shown.max_text mx, my = widget_offset(state.customization, "compact", "max_speed") center = widget_size(state.customization, 'compact', 'max_speed')[0] / 2 + mx if set_speed_alpha > 0.01 and placement(state.customization, "compact", "max_speed")["enabled"]: red, green, blue, alpha = rgba(state.customization, "text", "compact", "max_speed") color = rl.Color(red, green, blue, int(alpha * set_speed_alpha)) - draw_caption(self.fonts, 'SET', center, 20 + my, color, unit=shown.unit, size=24, centered=True) - size = 96 - left, right = self.fonts.horizontal_ink(cruise, FontRole.SET_SPEED, size) - size *= min(1, 128 / max(1, right - left)) - left, right = self.fonts.horizontal_ink(cruise, FontRole.SET_SPEED, size) - draw_ink(self.fonts, cruise, FontRole.SET_SPEED, size, center - (left + right) / 2, 46 + my, color) + draw_set_speed(self.fonts, shown, center, 16 + my, color) if status and placement(state.customization, 'compact', 'max_speed')['enabled']: color = rl.Color(241, 246, 243, 255) - left, right = self.fonts.horizontal_ink(status, FontRole.SEMI_BOLD, 24) - size = 24 * min(1, 128 / max(1, right - left)) - left, right = self.fonts.horizontal_ink(status, FontRole.SEMI_BOLD, size) - draw_ink(self.fonts, status, FontRole.SEMI_BOLD, size, center - (left + right) / 2, 128 + my, color) + draw_set_status(self.fonts, status, center, 16 + 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) diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py index 5226ec73f3..5074a10ed0 100644 --- a/openpilot/starpilot/ui/onroad_customization.py +++ b/openpilot/starpilot/ui/onroad_customization.py @@ -9,7 +9,7 @@ from typing import Any, NotRequired, TypedDict from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint -from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, confirmation_bounds, confirmation_footprint +from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, confirmation_footprint from openpilot.starpilot.ui.onroad_stopped_timer import timer_size from openpilot.starpilot.ui.onroad_favorites_geometry import shelf_metadata from openpilot.starpilot.ui.large_speed_geometry import (WIDTH, CARD_HEIGHT, ACTION_WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y, @@ -342,7 +342,7 @@ def customization_metadata(): def default_document(): - return {"version": 13, "clock24Hour": False, "reduceMotion": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, + return {"version": 14, "clock24Hour": False, "reduceMotion": 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()} @@ -431,7 +431,9 @@ def confirmation_layout_valid(layout, profile, widgets, bounds, *, extra_height= at = layout[owner] if not at['enabled'] or not layout['speed_limit_actions']['enabled']: return True - left, top, right, bottom = confirmation_bounds(profile, at['x'], at['y'], opened=True) + area = confirmation_footprint(profile) + left, top = at['x'] + area['x'], at['y'] + area['y'] + right, bottom = left + area['width'], top + area['height'] if extra_height: left, right = min(left, at['x']), max(right, at['x'] + widgets[owner]['width']) bottom = max(bottom, at['y'] + widgets[owner]['height'] + extra_height) @@ -473,7 +475,7 @@ def migrate_confirmation_layout(layout, profile, widgets, bounds, *, extra_heigh 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, 14): + if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 15): raise ValueError("Invalid customization version") fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set()) if value["version"] >= 3: @@ -745,6 +747,7 @@ def validate_document(value): old_gap += GAP if result.get('speedSourcesCard') is True else 0 if actions['x'] == card['x'] + ACTION_X - 88 and actions['y'] == card['y'] + old_gap: actions['y'] = card['y'] + ACTION_Y - 75 + if value['version'] < 14: migrate_confirmation_layout(result['layouts'][profile], profile, data['widgets'], data['bounds'], extra_height=sources_extent(result) if profile == 'large' else 0) elif not confirmation_layout_valid(result['layouts'][profile], profile, data['widgets'], data['bounds'], diff --git a/openpilot/starpilot/ui/onroad_large_widgets.py b/openpilot/starpilot/ui/onroad_large_widgets.py index 7e454aba77..77dfb33ffa 100644 --- a/openpilot/starpilot/ui/onroad_large_widgets.py +++ b/openpilot/starpilot/ui/onroad_large_widgets.py @@ -11,12 +11,12 @@ import json from openpilot.starpilot.ui.raylib import rl from openpilot.starpilot.ui.onroad_customization import offset, rgba, widget_size -from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds, slc_action_occludes +from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, slc_action_occludes from openpilot.starpilot.ui.onroad_widget_style import draw_control_card from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed -from openpilot.starpilot.ui.speed_limit_widget import MUTED, SpeedLimitWidget, draw_caption, draw_ink, draw_line, draw_text, set_speed_status +from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget, draw_set_speed, draw_set_status, draw_text, set_speed_status ENGAGED = rl.Color(128, 216, 166, 255) @@ -65,7 +65,7 @@ class UnifiedSpeedWidget: draw_text(self.fonts, text, role, font_size, box.x + (box.width - width) / 2, y, color) def render(self, content: rl.Rectangle, state: OnroadState) -> None: - from openpilot.starpilot.ui.large_speed_geometry import GAP, WIDTH, PADDING, SOURCE_ROW_HEIGHT, SOURCE_TOP, card_height + from openpilot.starpilot.ui.large_speed_geometry import GAP, WIDTH, SOURCE_ROW_HEIGHT, SOURCE_TOP, card_height from openpilot.starpilot.ui.onroad_customization import placement shown = resolve_unified_speed(state) if shown.mode == 'hidden': @@ -79,31 +79,10 @@ class UnifiedSpeedWidget: border = rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits')) text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits')) if shown.mode != 'limit_only': - confirming = manual_slc_confirmation(Profile.LARGE, state) - center = rect.x + 120 - x = rect.x + PADDING if confirming else center - draw_caption(self.fonts, 'SET', x, rect.y + 12, unit=shown.unit, centered=not confirming) - if shown.max_text == '–': - draw_line(Profile.LARGE, rl.Vector2(center - 28, rect.y + 90), rl.Vector2(center + 28, rect.y + 90), 8, MUTED) - else: - size = 100 - size *= min(1, (148 if confirming else 200) / max(1, self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width)) - left, right = self.fonts.horizontal_ink(shown.max_text, FontRole.SET_SPEED, size) - top, bottom = self.fonts.vertical_ink(shown.max_text, FontRole.SET_SPEED, size) - number_x = x + self.fonts.horizontal_ink('SET', FontRole.MEDIUM, 34)[0] - left if confirming else center - (left + right) / 2 - draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, number_x, rect.y + 90 - (bottom - top) / 2, text) - if shown.mode != 'limit_only' and (label := set_speed_status(state, Profile.LARGE, shown)): - confirming = manual_slc_confirmation(Profile.LARGE, state) - size = 24 - left, right = self.fonts.horizontal_ink(label, FontRole.SEMI_BOLD, size) - size *= min(1, (148 if confirming else 200) / max(1, right - left)) - left, right = self.fonts.horizontal_ink(label, FontRole.SEMI_BOLD, size) - x = rect.x + (PADDING + self.fonts.horizontal_ink('SET', FontRole.MEDIUM, 34)[0] - left if confirming else 120 - (left + right) / 2) - draw_ink(self.fonts, label, FontRole.SEMI_BOLD, size, x, rect.y + 136, text) + draw_set_speed(self.fonts, shown, rect.x + 92, rect.y, text) + if label := set_speed_status(state, Profile.LARGE, shown): + draw_set_status(self.fonts, label, rect.x + 92, rect.y, text) self.sign.render(state, defer_confirmation=True) - if (shown.mode == 'limit_only' and not manual_slc_confirmation(Profile.LARGE, state) and - (bounds := speed_limit_bounds(Profile.LARGE, state)) is not None): - draw_ink(self.fonts, shown.unit, FontRole.NORMAL, 24, bounds[2], bounds[3] - 32) if sources: row = rl.Rectangle(rect.x, rect.y + max(height, SOURCE_TOP) + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT) if separate: diff --git a/openpilot/starpilot/ui/speed_limit_widget.py b/openpilot/starpilot/ui/speed_limit_widget.py index 95aebcb62e..bf6d6ea9a1 100644 --- a/openpilot/starpilot/ui/speed_limit_widget.py +++ b/openpilot/starpilot/ui/speed_limit_widget.py @@ -21,6 +21,8 @@ IN_USE = rl.Color(128, 216, 166, 255) OVERRIDE = rl.Color(214, 188, 240, 255) REJECT = rl.Color(242, 139, 139, 255) SOURCE_LABELS = {'dashboard': 'DASH', 'map': 'OSM', 'vision': 'VISION', 'mapbox': 'NAV'} +NUMBER_SIZE, HEADING_SIZE = 70, 26 +HEADING_ROW, NUMBER_ROW, SOURCE_ROW = 25, 68, 120 def draw_text(fonts, text, role, size, x, y, color=INK): @@ -45,24 +47,32 @@ def draw_ink(fonts, text, role, size, x, top, color=INK): return fonts.measure(text, role, size).width, ink_bottom - ink_top -def draw_caption(fonts, label, x, top, color=MUTED, *, unit=None, size=None, centered=False): - large = fonts.profile == Profile.LARGE - label_size = size or (34 if large else 24) - unit_size, gap = ((32 if centered else 28), 12) if large else (20, 8) - if centered: - width = fonts.measure(label, FontRole.MEDIUM, label_size).width - if unit: - width += gap + fonts.measure(unit, FontRole.NORMAL, unit_size).width - x -= width / 2 - width, height = draw_ink(fonts, label, FontRole.MEDIUM, label_size, x, top, color) - if unit: - unit_top, _ = fonts.vertical_ink(unit, FontRole.NORMAL, unit_size) - # Exclude the p descender when aligning mph to the caption baseline. - _, baseline = fonts.vertical_ink('mh' if unit == 'mph' else unit, FontRole.NORMAL, unit_size) - unit_width, _ = draw_ink(fonts, unit, FontRole.NORMAL, unit_size, x + width + gap, - top + height - (baseline - unit_top), color) - width += gap + unit_width - return width, height +def draw_speed_row(fonts, text, role, size, center, row, color, reference): + # Shared glyph references keep SET and SLC on the same baseline as values change. + left, right = fonts.horizontal_ink(text, role, size) + top, bottom = fonts.vertical_ink(reference, role, size) + draw_text(fonts, text, role, size, center - (left + right) / 2, row - (top + bottom) / 2, color) + + +def draw_set_speed(fonts, shown, center, top, color): + scale = LARGE_SIGN_SCALE if fonts.profile == Profile.LARGE else 1. + text_scale = scale * Profile.COMPACT.font_scale / fonts.profile.font_scale + draw_speed_row(fonts, 'SET', FontRole.SEMI_BOLD, HEADING_SIZE * text_scale, center, + top + HEADING_ROW * scale, color, 'H') + if shown.max_text == '–': + draw_line(fonts.profile, rl.Vector2(center - 20 * scale, top + NUMBER_ROW * scale), + rl.Vector2(center + 20 * scale, top + NUMBER_ROW * scale), 5 * scale, color) + else: + draw_speed_row(fonts, shown.max_text, FontRole.SET_SPEED, NUMBER_SIZE * text_scale, center, + top + NUMBER_ROW * scale, color, '0123456789') + + +def draw_set_status(fonts, label, center, top, color): + scale = LARGE_SIGN_SCALE if fonts.profile == Profile.LARGE else 1. + size = 22 * scale * Profile.COMPACT.font_scale / fonts.profile.font_scale + left, right = fonts.horizontal_ink(label, FontRole.MEDIUM, size) + size *= min(1, 108 * scale / max(1, right - left)) + draw_speed_row(fonts, label, FontRole.MEDIUM, size, center, top + SOURCE_ROW * scale, color, label) def speed_status(state, profile, shown): @@ -155,11 +165,14 @@ class SpeedLimitWidget: y + cy * scale - (top + bottom) / 2, color) if state.slc_prompt_open: - context = (f'Now: {current_status}' + (f' {shown.unit}' if current_status != 'PEDAL' else '') - if current_status else 'New limit') + context = f'Now: {current_status}' if current_status else 'NEW LIMIT' context_size = 26 if self.profile == Profile.LARGE else 24 context_size *= min(1, (width - 24) / max(1, ink_width(context, context_size))) text(context, context_size, width / 2, 24, secondary) + label_size, number_size, detail_size, gap = (30, 58, 26, 8) if self.profile == Profile.LARGE else (26, 50, 24, 6) + # Reserve the full label height so capitalization does not shift either row. + heading_height = max(ink_height(label, label_size, FontRole.SEMI_BOLD) for label in ('Accept', 'Reject')) + number_height = ink_height(shown.proposal_text, number_size, FontRole.SET_SPEED) for control in slc_controls(self.profile, state): accent = (IN_USE if control.label == 'Accept' else REJECT) if control.enabled else MUTED selected = control.enabled and pressed == control.request @@ -169,70 +182,66 @@ class SpeedLimitWidget: w, h = (control.bounds[2] - control.bounds[0]) / scale, (control.bounds[3] - control.bounds[1]) / scale self.shapes.button(x + (left + w / 2) * scale, y + (top + h / 2) * scale, accent, profile=self.profile, scale=scale, pressed=selected) - uw = ink_width(shown.unit, 20) - size = 60 if self.profile == Profile.LARGE else 52 - padding, gap = (28, 6) if self.profile == Profile.LARGE else (24, 4) - size *= min(1, (w - padding - gap - uw) / max(1, ink_width(shown.proposal_text, size, FontRole.BOLD))) - nh, uh = ink_height(shown.proposal_text, size, FontRole.BOLD), ink_height(shown.unit, 20) - caption_height = ink_height('new limit', 22) - row_height = max(nh, uh, caption_height) - # Center each visible text group while keeping both action headings on one row. - label_y = top + (h - 8 - row_height) / 2 - detail_bottom = label_y + ink_height(control.label, 37) / 2 + 8 + row_height - text(control.label, 37, left + w / 2, label_y, ink) + start = top + (h - heading_height - gap - number_height) / 2 + text(control.label.upper(), label_size, left + w / 2, start + heading_height / 2, ink, FontRole.SEMI_BOLD) + detail_y = start + heading_height + gap + number_height / 2 if control.label == 'Accept': # Bind the adjusted proposal to its action, not to the current override or source reading. - nw = ink_width(shown.proposal_text, size, FontRole.BOLD) - start = left + (w - nw - gap - uw) / 2 - text(shown.proposal_text, size, start + nw / 2, detail_bottom - nh / 2, ink, FontRole.BOLD) - text(shown.unit, 20, start + nw + gap + uw / 2, detail_bottom - uh / 2, ink) + text(shown.proposal_text, number_size, left + w / 2, detail_y, ink, FontRole.SET_SPEED) else: - text('new limit', 22, left + w / 2, detail_bottom - caption_height / 2, ink) + text('NEW LIMIT', detail_size, left + w / 2, detail_y, ink) return + content_top = y + (12 if self.profile == Profile.LARGE else 8) + row_origin = (content_top - y) / scale + caption_row, hint_row = (112, 142) if self.profile == Profile.LARGE else (110, 136.75) source_color = rl.Color(197, 212, 208, 255) if current_status: source += f' / {current_status}' - label_size = 22 * min(1, (width - 40) / max(1, ink_width(source, 22, FontRole.SEMI_BOLD))) + label_size = HEADING_SIZE * min(1, (width - 40) / max(1, ink_width(source, HEADING_SIZE, FontRole.SEMI_BOLD))) label_width = ink_width(source, label_size, FontRole.SEMI_BOLD) target = state.speed_limit.pending_adjusted_limit_mps if not positive(target): target = displayed_adjusted_limit_mps(state.speed_limit) direction = (-1 if target > state.speed_mps else 1) if target is not None and state.speed_mps is not None and target != state.speed_mps else 0 start = (width - label_width - (19.2 if direction else 0)) / 2 - text(source, label_size, start + label_width / 2, 22, source_color, FontRole.SEMI_BOLD) + draw_speed_row(self.fonts, source, FontRole.SEMI_BOLD, text_size(label_size), + x + (start + label_width / 2) * scale, content_top + HEADING_ROW * scale, source_color, 'H') if direction: - self.shapes.arrow('compact', x + (start + label_width + 12.6) * scale, y + 22.5 * scale, + self.shapes.arrow('compact', x + (start + label_width + 12.6) * scale, content_top + (HEADING_ROW + .5) * scale, direction, source_color, scale=1.1 * scale) - nw = ink_width(shown.posted_text, 64, FontRole.BOLD) + nw = ink_width(shown.posted_text, NUMBER_SIZE, FontRole.SET_SPEED) ow = ink_width(shown.offset_text, 32) if shown.offset_text else 0 start = (width - nw - (10 + ow if ow else 0)) / 2 - # Align the posted value and offset at their visible lower edge. - number_top, number_bottom = self.fonts.vertical_ink(shown.posted_text, FontRole.BOLD, text_size(64)) - text(shown.posted_text, 64, start + nw / 2, 84 - (number_bottom - number_top) / scale / 2, white, FontRole.BOLD) + draw_speed_row(self.fonts, shown.posted_text, FontRole.SET_SPEED, text_size(NUMBER_SIZE), + x + (start + nw / 2) * scale, content_top + NUMBER_ROW * scale, white, '0123456789') if ow: offset_text = cast(str, shown.offset_text) + ref_top, ref_bottom = self.fonts.vertical_ink('0123456789', FontRole.SET_SPEED, text_size(NUMBER_SIZE)) + _, ink_bottom = self.fonts.vertical_ink(shown.posted_text, FontRole.SET_SPEED, text_size(NUMBER_SIZE)) + number_bottom = row_origin + NUMBER_ROW + (ink_bottom - (ref_top + ref_bottom) / 2) / scale top, bottom = self.fonts.vertical_ink(offset_text, FontRole.MEDIUM, text_size(32)) - text(offset_text, 32, start + nw + 10 + ow / 2, 84 - (bottom - top) / scale / 2, secondary) - label = 'New Limit' if slc_accept_available(state) else 'Unavailable' + text(offset_text, 32, start + nw + 10 + ow / 2, number_bottom - (bottom - top) / scale / 2, secondary) + label = 'NEW LIMIT' if slc_accept_available(state) else 'Unavailable' caption = ink_width(label, 30, FontRole.SEMI_BOLD) cx = (width - caption - 10) / 2 strength = self.pulse.confirmation_strength(now) accent = rl.Color(214, round(188 + 35 * strength), 240, 255) - text(label, 30, cx + caption / 2, 104, accent, FontRole.SEMI_BOLD) - ax, ay = x + (cx + caption + 8) * scale, y + 104 * scale + text(label, 30, cx + caption / 2, row_origin + caption_row, accent, FontRole.SEMI_BOLD) + ax, ay = x + (cx + caption + 8) * scale, content_top + caption_row * scale draw_line(self.profile, rl.Vector2(ax - 2 * scale, ay - 4 * scale), rl.Vector2(ax + 2 * scale, ay), 1.5 * scale, accent) draw_line(self.profile, rl.Vector2(ax + 2 * scale, ay), rl.Vector2(ax - 2 * scale, ay + 4 * scale), 1.5 * scale, accent) if state.slc_wheel_confirmation_available: - accept, reject = ink_width('Accept', 26), ink_width('Reject', 26) + hint_size = 23 + accept, reject = ink_width('ACCEPT', hint_size), ink_width('REJECT', hint_size) start = (width - 18 - 4 - accept - 10 - 18 - 4 - reject) / 2 for left, plus in ((start, True), (start + 18 + 4 + accept + 10, False)): - self.shapes.wheel_key(plus, x + (left + 9) * scale, y + 135.5 * scale, scale=scale) + self.shapes.wheel_key(plus, x + (left + 9) * scale, content_top + hint_row * scale, scale=scale) hint_ink = rl.Color(210, 216, 216, 255) - text('Accept', 26, start + 22 + accept / 2, 135.5, hint_ink if slc_accept_available(state) else MUTED) - text('Reject', 26, start + 18 + 4 + accept + 10 + 22 + reject / 2, 135.5, hint_ink) + text('ACCEPT', hint_size, start + 22 + accept / 2, row_origin + hint_row, hint_ink if slc_accept_available(state) else MUTED) + text('REJECT', hint_size, start + 18 + 4 + accept + 10 + 22 + reject / 2, row_origin + hint_row, hint_ink) else: - text('Tap to review', 22, width / 2, 135.5, secondary) + text('Tap to review', 22, width / 2, row_origin + hint_row, secondary) def _sign(self, state: OnroadState, bounds, now: float) -> None: shown, obs = resolve_unified_speed(state), state.speed_limit @@ -282,21 +291,24 @@ class SpeedLimitWidget: label = 'Override' if shown.override_text is not None else 'Limit' active_text = active_text or shown.slc_target_text or shown.posted_text target_rows = (('Pedal', FontRole.SEMI_BOLD, 44, 68, OVERRIDE),) if pedal else ( - (label, FontRole.SEMI_BOLD, 34, 25, OVERRIDE if label == 'Override' else white), - (active_text, FontRole.SET_SPEED if self.profile == Profile.LARGE else FontRole.BOLD, 64, 68, white)) + (label, FontRole.SEMI_BOLD, HEADING_SIZE, HEADING_ROW, OVERRIDE if label == 'Override' else white), + (active_text, FontRole.SET_SPEED, NUMBER_SIZE, NUMBER_ROW, white)) posted = shown.posted_text + (f' {shown.offset_text}' if shown.offset_text else '') detail = f'{heading} {posted}' if pedal or shown.override_text is not None or active_text != shown.posted_text else heading left, right, _, _ = ink(detail, FontRole.MEDIUM, 22) - source_rows = ((detail, FontRole.MEDIUM, 22, 120, secondary),) if detail == heading or right - left <= 108 else ( - (heading, FontRole.SEMI_BOLD, 18, 104, secondary), (posted, FontRole.MEDIUM, 20, 125, secondary)) + source_rows = ((detail, FontRole.MEDIUM, 22, SOURCE_ROW, secondary),) if detail == heading or right - left <= 108 else ( + (heading, FontRole.SEMI_BOLD, 18, 106, secondary), (posted, FontRole.MEDIUM, 20, 125, secondary)) for value, role, size, cy, color in (*target_rows, *source_rows): left, right, _, _ = ink(value, role, size) size *= min(1, 108 / max(1, right - left)) _, _, a, b = ink(value, role, size) - centered(value, role, size, cy - (b - a) / 2, color) + if role == FontRole.SET_SPEED or value in ('Limit', 'Override'): + draw_speed_row(self.fonts, value, role, size * self.text_scale, x + center_x * scale, top + cy * scale, + color, '0123456789' if role == FontRole.SET_SPEED else 'H') + else: + centered(value, role, size, cy - (b - a) / 2, color) return - large_steady = self.profile == Profile.LARGE and not vienna and not shown.pending color = white if vienna: x, top = x + 5 * scale, top + 5 * scale @@ -305,7 +317,7 @@ class SpeedLimitWidget: else: self.shapes.outline(x + center_x * scale, top + sign_center_y * scale, IN_USE if shown.limit_in_use else white) - heading_size = 28 if not vienna else 18 + heading_size = HEADING_SIZE if not vienna else 18 if vienna and self.profile == Profile.LARGE and not shown.pending: heading_size = 21 left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size) @@ -314,7 +326,7 @@ class SpeedLimitWidget: left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size) # Lift the Vienna stack to keep the offset clear of the bottom rim. lift = (12 if shown.limit_in_use else 7 if shown.offset_text else 2) if vienna else 0 - header_y = 47 - lift if vienna else 26 + header_y = 47 - lift if vienna else HEADING_ROW arrow_scale = 1. if vienna: # Fit the heading and arrow inside the circle at the top of their ink. @@ -335,10 +347,10 @@ class SpeedLimitWidget: self.shapes.arrow('compact', round(x + (header_left + heading_width + 6 + arrow_width / 2) * scale), round(top + header_y * scale), arrow_direction, color, scale=scale * arrow_scale) - role = FontRole.SET_SPEED if large_steady else FontRole.BOLD - size = 58 if vienna else 64 + role = FontRole.BOLD if vienna else FontRole.SET_SPEED + size = 58 if vienna else NUMBER_SIZE offset_size = 30 if vienna else 27 - number_center = (73 if shown.offset_text else 80) - lift if vienna else (68 if shown.offset_text else 80) + number_center = (73 if shown.offset_text else 80) - lift if vienna else NUMBER_ROW while True: left, right, a, b = ink(shown.posted_text, role, size) width, height = right - left, b - a @@ -356,7 +368,11 @@ class SpeedLimitWidget: size *= .95 if vienna: offset_size *= .95 - centered(shown.posted_text, role, size, number_top, color) + if vienna: + centered(shown.posted_text, role, size, number_top, color) + else: + draw_speed_row(self.fonts, shown.posted_text, role, size * self.text_scale, x + center_x * scale, + top + NUMBER_ROW * scale, color, '0123456789') if shown.offset_text: if not vienna: left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, offset_size) diff --git a/openpilot/starpilot/ui/tests/test_dinish_speed.py b/openpilot/starpilot/ui/tests/test_dinish_speed.py index b179369b75..382c40ef46 100644 --- a/openpilot/starpilot/ui/tests/test_dinish_speed.py +++ b/openpilot/starpilot/ui/tests/test_dinish_speed.py @@ -71,7 +71,7 @@ def test_approved_limit_offset_and_action_sizes_fit_actual_fonts(): rl.close_window() -def test_large_sign_is_secondary_to_set_speed_at_the_saved_position(): +def test_large_set_and_limit_share_typography_at_the_saved_position(): from unittest.mock import patch from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget from openpilot.starpilot.ui.layout_preview_renderer import sample_state @@ -99,7 +99,7 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position(): assert right-left <= 108*LARGE_SIGN_SCALE assert abs(posted[3] + (left + right) / 2 - (418 + SIGN_WIDTH/2*LARGE_SIGN_SCALE)) <= .5 a, b = fonts.vertical_ink('130', FontRole.SET_SPEED, posted[2]) - assert 30 <= b - a <= 64 + assert 60 <= b - a <= 70 assert abs(posted[4]+(a+b)/2-(95+68*LARGE_SIGN_SCALE)) <= .5 assert {'Limit', '120 +10'} <= {call[0] for call in calls} set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED and call[3] < 388] @@ -108,19 +108,20 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position(): text, role, size, x, y, _ = set_calls[0] assert fonts.measure(text, role, size).width <= 200 left, right = fonts.horizontal_ink(text, role, size) - assert x + (left + right) / 2 == pytest.approx(308) + assert x + (left + right) / 2 == pytest.approx(280) assert x + right + 20 <= 418 top, bottom = fonts.vertical_ink(text, role, size) - assert bottom - top == pytest.approx(77, abs=1) - assert y + (top + bottom) / 2 == pytest.approx(185) - assert next(call for call in calls if call[0] == 'SET')[2] == 34 - unit = next(call for call in calls if call[0] == ('km/h' if metric else 'mph')) - assert unit[2] == 32 - assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] + 6 <= y + top + assert (role, size, y) == (posted[1], posted[2], posted[4]) + heading = next(call for call in calls if call[0] == 'SET') + limit = next(call for call in calls if call[0] == 'Limit') + assert (heading[1:3], heading[4]) == (limit[1:3], limit[4]) + assert not any('mph' in call[0] or 'km/h' in call[0] for call in calls) if show_set and value is None: start, end, stroke, color = lines.call_args.args - assert (start.x, start.y, end.x, end.y, stroke) == (280, 185, 336, 185, 8) - assert (color.r, color.g, color.b, color.a) == (190, 208, 202, 255) + assert (start.x, start.y, end.x, end.y, stroke) == pytest.approx( + (280 - 20 * LARGE_SIGN_SCALE, 95 + 68 * LARGE_SIGN_SCALE, + 280 + 20 * LARGE_SIGN_SCALE, 95 + 68 * LARGE_SIGN_SCALE, 5 * LARGE_SIGN_SCALE)) + assert (color.r, color.g, color.b, color.a) == (255, 255, 255, 255) assert all(call[0] not in ('–', '?') for call in calls) else: lines.assert_not_called() @@ -129,6 +130,51 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position(): rl.close_window() +@pytest.mark.parametrize('pending', [False, True]) +def test_set_and_slc_match_font_and_baseline_on_both_displays(pending): + from unittest.mock import patch + from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer + from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget + 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, 'Shared SET and SLC typography') + try: + for profile in Profile: + with BitmapFonts(profile, default_font_directory()) as fonts: + widget = UnifiedSpeedWidget(fonts) if profile == Profile.LARGE else CompactHudRenderer(fonts, Path('/unused')) + try: + for value in (9, 65, 128): + current = state(cruise_kph=140) + current = replace(current, speed_limit=replace(current.speed_limit, speed_limit_mps=value / 3.6, + pending_speed_limit_mps=value / 3.6 if pending else None, + pending_adjusted_limit_mps=value / 3.6 if pending else None, + presentation_adjusted_limit_mps=value / 3.6, + limiting_max_set=True, effective_cluster_target_mps=value / 3.6)) + calls = [] + with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)): + if isinstance(widget, UnifiedSpeedWidget): + widget.render(rl.Rectangle(0, 0, 2160, 1080), current) + else: + with patch.object(widget, '_set_speed_opacity', return_value=1.): + widget.render_max_speed(current) + widget._speed_limit_sign(current) + if pending: + sign = widget.sign if isinstance(widget, UnifiedSpeedWidget) else widget._speed_limit_widget + sign.render_confirmation(current) + rows = {row[0]: row for row in calls if row[-1].a == 255} + for a, b in (('SET', 'OSM' if pending else 'Limit'), ('140', str(value))): + assert rows[a][1] == rows[b][1] + assert rows[a][2] == pytest.approx(rows[b][2]) + assert rows[a][4] == pytest.approx(rows[b][4]) + assert not any('mph' in text or 'km/h' in text for text in rows) + finally: + widget.close() + finally: + if owns_context: + rl.close_window() + + def test_pending_confirmation_keeps_three_digit_set_speed_clear(): from unittest.mock import patch from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget @@ -190,18 +236,17 @@ def test_status_below_set_has_real_ink_clearance_and_fits_its_widget(): saved = placement(current.customization, profile, key) width, height = widget_size(current.customization, profile, key) assert caption[4] + fonts.vertical_ink(*caption[:3])[1] <= saved['y'] + height - 12 - assert 18 <= caption[2] <= 24 + scale = LARGE_SIGN_SCALE if profile == Profile.LARGE else 1 + assert 16 <= caption[2] * profile.font_scale / Profile.COMPACT.font_scale / scale <= 22 + 1e-9 left, right = fonts.horizontal_ink(*caption[:3]) number_left, number_right = fonts.horizontal_ink(*number[:3]) if profile == Profile.COMPACT: assert saved['x'] + 8 <= caption[3] + left < caption[3] + right <= saved['x'] + width - 8 assert caption[3] + (left + right) / 2 == pytest.approx(saved['x'] + width / 2) assert number[3] + (number_left + number_right) / 2 == pytest.approx(saved['x'] + width / 2) - elif status != 'PEDAL': - label = next(row for row in rows if row[0] == 'SET') - label_left = label[3] + fonts.horizontal_ink(*label[:3])[0] - assert caption[3] + left == pytest.approx(label_left) - assert number[3] + number_left == pytest.approx(label_left) + else: + assert caption[3] + (left + right) / 2 == pytest.approx(saved['x'] + 92) + assert number[3] + (number_left + number_right) / 2 == pytest.approx(saved['x'] + 92) widget.close() finally: if owns_context: @@ -388,7 +433,7 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign(): rl.init_window(128, 128, 'SLC preview crop fixture') canvas = _Canvas(Profile.COMPACT) try: - for y, scene, vienna in product((16, 20), ('slc_pending', 'slc_retained', 'slc_display'), (False, True)): + for y, scene, vienna in product((12, 16), ('slc_notice', 'slc_pending', 'slc_retained', 'slc_display'), (False, True)): document = default_document() document['layouts']['compact']['speed_limit']['y'] = y assert validate_document(document)['layouts']['compact']['speed_limit']['y'] == y @@ -408,8 +453,8 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign(): finally: rl.unload_image(image) assert np.array_equal(direct[..., 3], np.asarray(rebuilt)[..., 3]) - if scene == 'slc_pending': - assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-172, -8, 328, 152) + if scene in ('slc_notice', 'slc_pending'): + assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-172, -8, 328, 159) else: assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-2, 0, SIGN_WIDTH+4, SIGN_HEIGHT) assert np.any(direct[..., 3]), (y, scene) @@ -448,7 +493,7 @@ def test_large_sources_and_confirmation_text_have_clearance_below_both_signs(): view._slc_actions(current) foreground = [call for call in calls if call[-1].a == 255] action_bottom = max(y+fonts.vertical_ink(text, role, size)[1] - for text, role, size, _, y, _ in foreground if text in ('Accept', 'Reject')) + for text, role, size, _, y, _ in foreground if text in ('ACCEPT', 'REJECT')) source_top = min(y+fonts.vertical_ink(text, role, size)[0] for text, role, size, _, y, _ in foreground if text in ('Dash', 'Map', 'Vision')) assert action_bottom+8 <= source_top @@ -608,7 +653,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale(): rl.close_window() -def test_sign_profiles_share_layout_and_snap_only_final_pixels(): +def test_sign_profiles_share_layout_and_keep_aligned_numbers(): from unittest.mock import Mock, patch from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget from openpilot.starpilot.ui.tests.test_unified_speed_native import state @@ -631,7 +676,8 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels(): with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)): widget.render(current) calls = [call for call in calls if call[-1].a == 255] - assert all(float(call[3]).is_integer() and float(call[4]).is_integer() for call in calls) + assert all(float(call[3]).is_integer() and float(call[4]).is_integer() + for call in calls if call[1] != FontRole.SET_SPEED) if not vienna: assert calls[0][2]/widget.text_scale <= 28 elif fonts.profile == Profile.LARGE and not pending: @@ -644,10 +690,8 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels(): small_widget, small_calls = drawn[0] large_widget, large_calls = drawn[1] assert [call[0] for call in small_calls] == [call[0] for call in large_calls] - if not vienna and not pending: - assert small_calls[1][1] == FontRole.BOLD - assert large_calls[1][1] == FontRole.SET_SPEED - continue + if not vienna: + assert small_calls[1][1] == large_calls[1][1] == FontRole.SET_SPEED start = int(vienna and not pending) for a, b in zip(small_calls[start:], large_calls[start:], strict=True): assert abs(a[2]/small_widget.text_scale-b[2]/large_widget.text_scale) < 1e-9 @@ -673,7 +717,7 @@ 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'] == 13 + assert migrated['version'] == 14 assert migrated['layouts']['large']['cruise_limits'] == default_document()['layouts']['large']['cruise_limits'] assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 882, 'enabled': True} assert migrated['speedSources'] is True @@ -720,10 +764,12 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped(): -def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_control(): +@pytest.mark.parametrize('wheel_confirmation', [False, True]) +def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_control(wheel_confirmation): from unittest.mock import patch from openpilot.starpilot.ui.large_speed_geometry import CONFIRMATION_SCALE from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget + from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, SPRITES, SPRITE_DENSITIES from openpilot.starpilot.ui.tests.test_slc_ui_actions import state owns_context = not rl.is_window_ready() if owns_context: @@ -746,7 +792,7 @@ def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_ pending_adjusted_limit_mps=(posted + offset) / factor, presentation_adjusted_limit_mps=(posted + offset) / factor) current = replace(current, speed_limit=obs, metric=metric, show_slc_offset=show_offset, - slc_wheel_confirmation_available=True) + slc_wheel_confirmation_available=wheel_confirmation) notice_calls = [] with patch.object(fonts, 'draw', lambda *args, sink=notice_calls: sink.append(args)): widget.render_confirmation(current) @@ -764,8 +810,8 @@ def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_ right = boxes[f'{offset:+d}'][2] if show_offset else number[2] assert (number[0] + right) / 2 == pytest.approx((notice_bounds[0] + notice_bounds[2]) / 2) assert boxes['VISION'][3] + 6 * scale <= number[1] - assert number[3] + 6 * scale <= boxes['New Limit'][1] - assert boxes['New Limit'][3] + 6 * scale <= boxes['Accept'][1] + assert number[3] + 6 * scale <= boxes['NEW LIMIT'][1] + assert boxes['NEW LIMIT'][3] + 6 * scale <= boxes['ACCEPT' if wheel_confirmation else 'Tap to review'][1] if show_offset: assert boxes[f'{offset:+d}'][3] == pytest.approx(number[3]) else: @@ -781,9 +827,8 @@ def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_ with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), patch.object(rl, 'draw_rectangle_rounded'): widget.render_confirmation(current) calls = [call for call in calls if call[-1].a == 255] - context = 'Now: OVERRIDE 128 km/h' if posted == 110 else 'New limit' - assert [call[0] for call in calls] == [context, 'Accept', str(posted + offset), - 'km/h' if metric else 'mph', 'Reject', 'new limit'] + context = 'Now: OVERRIDE 128' if posted == 110 else 'NEW LIMIT' + assert [call[0] for call in calls] == [context, 'ACCEPT', str(posted + offset), 'REJECT', 'NEW LIMIT'] bounds = speed_limit_bounds(profile, current) assert bounds is not None for text, role, size, x, y, _ in calls[:1]: @@ -792,31 +837,43 @@ def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_ assert x + left >= bounds[0] + 10 * scale assert x + right <= bounds[2] - 10 * scale assert bounds[1] + 8 * scale <= y + top < y + bottom <= bounds[1] + 40 * scale - heading_centers = [] - for control, content in zip(slc_controls(profile, current), (calls[1:4], calls[4:]), strict=True): + # Measure from the painted outline, which is smaller than the touch area. + name = f'button_{profile}' + sx, sy, sw, sh = SPRITES[name] + with Image.open(ATLAS_PATH) as atlas: + alpha = np.asarray(atlas.crop((sx, sy, sx + sw, sy + sh)).getchannel('A')) + horizontal = np.flatnonzero(alpha[sh // 2]) + vertical = np.flatnonzero(alpha[:, sw // 2]) + inner_left, inner_right = horizontal[horizontal < sw / 2][-1] + 1, horizontal[horizontal > sw / 2][0] + inner_top, inner_bottom = vertical[vertical < sh / 2][-1] + 1, vertical[vertical > sh / 2][0] + density = SPRITE_DENSITIES[name] + row_centers = [] + for control, content in zip(slc_controls(profile, current), (calls[1:3], calls[3:]), strict=True): label_bottom = 0 boxes = [] + cx, cy = (control.bounds[0] + control.bounds[2]) / 2, (control.bounds[1] + control.bounds[3]) / 2 + interior = (cx + (inner_left - sw / 2) * scale / density, cy + (inner_top - sh / 2) * scale / density, + cx + (inner_right - sw / 2) * scale / density, cy + (inner_bottom - sh / 2) * scale / density) + contour = 1.15 if profile == Profile.LARGE else 1. + padding = (14 if profile == Profile.LARGE else 9) * scale for text, role, size, x, y, _ in content: left, right = fonts.horizontal_ink(text, role, size) top, bottom = fonts.vertical_ink(text, role, size) boxes.append((x + left, y + top, x + right, y + bottom)) - assert x + left - control.bounds[0] >= 12 * scale - 1e-6 - assert control.bounds[2] - x - right >= 12 * scale - 1e-6 - assert control.bounds[1] + 10 * scale <= y + top < y + bottom <= control.bounds[3] - 10 * scale - if text == control.label: + assert min(x + left - interior[0], interior[2] - x - right, + y + top - interior[1], interior[3] - y - bottom) - contour >= padding + if text == control.label.upper(): label_bottom = y + bottom - assert (bottom - top) / scale * 25.4 / 300 >= 2.8 + assert (bottom - top) / scale * 25.4 / 300 >= (1.7 if profile == Profile.LARGE else 1.5) else: assert y + top >= label_bottom + 6 * scale - cx, cy = (control.bounds[0] + control.bounds[2]) / 2, (control.bounds[1] + control.bounds[3]) / 2 assert (boxes[0][0] + boxes[0][2]) / 2 == pytest.approx(cx) assert (min(box[0] for box in boxes[1:]) + max(box[2] for box in boxes[1:])) / 2 == pytest.approx(cx) - assert (min(box[1] for box in boxes) + max(box[3] for box in boxes)) / 2 == pytest.approx(cy) - heading_centers.append((boxes[0][1] + boxes[0][3]) / 2) + row_centers.append(tuple((box[1] + box[3]) / 2 for box in boxes)) if control.label == 'Accept': - assert boxes[1][3] == pytest.approx(boxes[2][3]), 'Speed and unit share a lower edge' + assert abs((boxes[0][1] + boxes[1][3]) / 2 - cy) < 3 * scale assert (boxes[1][3] - boxes[1][1]) / scale * 25.4 / 300 >= (3.3 if profile == Profile.LARGE else 2.7) - assert heading_centers[0] == pytest.approx(heading_centers[1]) + assert row_centers[0] == pytest.approx(row_centers[1]) finally: widget.close() finally: diff --git a/openpilot/starpilot/ui/tests/test_onroad_customization.py b/openpilot/starpilot/ui/tests/test_onroad_customization.py index edc14668ce..4c57e34408 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_customization.py +++ b/openpilot/starpilot/ui/tests/test_onroad_customization.py @@ -54,7 +54,7 @@ def test_v12_portrait_upgrade_preserves_preferences_and_is_idempotent(): before = copy.deepcopy(old) updated = validate_document(old) expected = copy.deepcopy(old) - expected['version'] = 13 + expected['version'] = 14 expected['layouts']['compact']['speed_limit'].update(x=320, y=16) assert old == before and updated == expected assert validate_document(updated) == updated @@ -74,6 +74,33 @@ def test_v12_edge_placements_remain_bounded_even_with_confirmation_disabled(): assert all(not layout['speed_limit_actions']['enabled'] for layout in updated['layouts'].values()) +@pytest.mark.parametrize('sign_y', [12, 16, 20]) +def test_v13_notice_upgrade_preserves_preferences_and_only_moves_a_conflicting_sign(sign_y): + import copy + old = default_document() + old['version'] = 13 + old['layouts']['compact']['speed_limit']['y'] = sign_y + old['layouts']['large']['current_speed'].update(x=700, enabled=False) + old['widgetColors']['large']['current_speed'] = {'text': '#ABCDEF80'} + old['reduceMotion'] = True + before = copy.deepcopy(old) + updated = validate_document(old) + expected = copy.deepcopy(old) + expected['version'] = 14 + expected['layouts']['compact']['speed_limit']['y'] = min(sign_y, 16) + assert old == before and updated == expected + assert validate_document(updated) == updated + + +def test_compact_notice_is_reserved_even_when_the_shorter_buttons_would_fit(): + document = default_document() + document['layouts']['compact']['speed_limit']['y'] = 20 + with pytest.raises(ValueError, match='confirmation'): + validate_document(document) + document['layouts']['compact']['speed_limit_actions']['enabled'] = False + assert validate_document(document) == document + + @pytest.mark.parametrize('separate', [False, True]) def test_v12_source_attachment_follows_the_new_row_without_moving_custom_actions(separate): old = default_document() @@ -165,7 +192,7 @@ 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'] == 13 and validate_document(migrated) == migrated + assert migrated['version'] == 14 and validate_document(migrated) == migrated assert migrated['layouts']['large']['cruise_limits']['x'] == 1437 assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 882, 'enabled': True} assert migrated['layouts']['large']['current_speed'] == original['layouts']['large']['current_speed'] @@ -195,7 +222,7 @@ def test_v8_restores_only_compact_speed_defaults_and_preserves_customizations(cu original = copy.deepcopy(document) migrated = validate_document(document) assert document == original - assert migrated['version'] == 13 and validate_document(migrated) == migrated + assert migrated['version'] == 14 and validate_document(migrated) == migrated assert migrated['layouts']['large'] == document['layouts']['large'] assert migrated['widgetColors'] == document['widgetColors'] assert migrated['widgetOrder'] == document['widgetOrder'] @@ -236,7 +263,7 @@ def test_v8_actionless_layout_preserves_customizations_when_adding_compact_actio original = copy.deepcopy(document) migrated = validate_document(document) assert document == original - assert migrated['version'] == 13 and validate_document(migrated) == migrated + assert migrated['version'] == 14 and validate_document(migrated) == migrated assert migrated['layouts']['large'] == {**original['layouts']['large'], 'speed_limit_actions': large_actions} assert migrated['layouts']['compact']['speed_limit'] == default_document()['layouts']['compact']['speed_limit'] assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 146, 'y': 44, 'enabled': True} @@ -281,7 +308,7 @@ def test_protected_compact_actions_reject_actual_overlap(widget, x, y): validate_document(document) -@pytest.mark.parametrize("widget,x,y", [("speed_limit", 320, 23), ("max_speed", 0, 0), ("steering_wheel", 78, 174)]) +@pytest.mark.parametrize("widget,x,y", [("speed_limit", 320, 16), ("max_speed", 0, 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) @@ -355,7 +382,7 @@ def test_v3_layout_migrates_wheel_size_without_changing_saved_placement_or_color for profile in ("large", "compact"): del document["layouts"][profile]["steering_wheel"]["size"] migrated = validate_document(document) - assert migrated["version"] == 13 + assert migrated["version"] == 14 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": 130, "enabled": True, "size": 50} @@ -407,7 +434,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": 13', '"version": 11, "version": 11').encode() + raw = json.dumps(document).replace('"version": 14', '"version": 11, "version": 11').encode() with patch("openpilot.starpilot.ui.onroad_customization.read_saved", return_value=(raw, True)): assert read_customization(Mock()) == default_document() @@ -423,7 +450,7 @@ def test_v9_large_sign_migration_preserves_customizations_and_clamps_bottom_edge before = deepcopy(old) updated = validate_document(old) assert old == before - assert updated['version'] == 13 + assert updated['version'] == 14 assert updated['layouts']['large']['cruise_limits']['y'] == default_document()['layouts']['large']['cruise_limits']['y'] assert updated['layouts']['large']['speed_limit_actions'] == before['layouts']['large']['speed_limit_actions'] assert updated['layouts']['compact'] == before['layouts']['compact'] @@ -533,7 +560,7 @@ def test_large_draw_origins_and_wheel_input_move_together(): with patch("openpilot.starpilot.ui.onroad_large_widgets.draw_control_card") as card, patch.object(rl, "draw_line_ex"): UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state) card.assert_not_called() - assert foreground_calls(fonts.draw)[0].args[3:5] == (292, 107) + assert foreground_calls(fonts.draw)[0].args[3:5] == pytest.approx((275, 117.14), abs=.01) CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state) assert foreground_calls(fonts.draw)[-2].args[3:5] == (965, 112) wheel = SteeringWheelWidget(Path('/unused')) @@ -566,7 +593,7 @@ def test_frozen_default_draw_geometry_and_neutral_colors(): UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state) card.assert_not_called() assert fonts.draw.call_args_list[0].args[0] == 'SET' - assert foreground_calls(fonts.draw)[0].args[3:5] == (192, 87) + assert foreground_calls(fonts.draw)[0].args[3:5] == pytest.approx((175, 97.14), abs=.01) CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state) assert foreground_calls(fonts.draw)[-2].args[3:5] == (925, 72) assert foreground_calls(fonts.draw)[-1].args[3:5] == pytest.approx((925, 215.2)) @@ -610,7 +637,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] == (125, 56) + assert foreground_calls(fonts.draw)[-1].args[3:5] == (138, 69.5) rect = wheel.call_args.args[2] assert (rect.x, rect.y) == (115, 175) alpha = hud._set_speed_alpha.x @@ -679,7 +706,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'] == 13 + assert result['version'] == 14 assert result['palette'] == old['palette'] for profile, layout in old['layouts'].items(): for key, position in layout.items(): diff --git a/openpilot/starpilot/ui/tests/test_onroad_parity.py b/openpilot/starpilot/ui/tests/test_onroad_parity.py index 262c19dfed..c2674343bf 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_parity.py +++ b/openpilot/starpilot/ui/tests/test_onroad_parity.py @@ -219,7 +219,7 @@ def test_pending_confirmation_omits_redundant_speed_heading(): with patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rounded'), \ patch.object(SpeedLimitShapes, 'button'): view._slc_actions(state) - assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['New limit', 'Accept', '35', 'mph', 'Reject', 'new limit'] + assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['NEW LIMIT', 'ACCEPT', '35', 'REJECT', 'NEW LIMIT'] def test_compact_pending_label_and_button_follow_saved_action_placement(): @@ -242,7 +242,7 @@ def test_compact_pending_label_and_button_follow_saved_action_placement(): with patch.object(rl, 'draw_rectangle_rounded') as button, patch.object(rl, 'draw_line_ex'), \ patch.object(SpeedLimitShapes, 'button'): view._slc_actions(state) - assert [call.args[0] for call in foreground_calls(view.fonts.draw) if call.args[0] in ('Accept', 'Reject')] == ['Accept', 'Reject'] + assert [call.args[0] for call in foreground_calls(view.fonts.draw) if call.args[0] in ('ACCEPT', 'REJECT')] == ['ACCEPT', 'REJECT'] button.assert_not_called() assert slc_controls(Profile.COMPACT, state)[0].bounds == (156., 48., 306., 160.) diff --git a/openpilot/starpilot/ui/tests/test_slc_confirmation.py b/openpilot/starpilot/ui/tests/test_slc_confirmation.py index 36c7dfae2d..6a855de1aa 100644 --- a/openpilot/starpilot/ui/tests/test_slc_confirmation.py +++ b/openpilot/starpilot/ui/tests/test_slc_confirmation.py @@ -50,7 +50,8 @@ def test_closed_and_open_card_share_anchor_and_physically_scaled_targets(profile opened = touch.present(current) after = speed_limit_bounds(profile, opened) assert before is not None and after is not None - assert before[1] == after[1] and before[3] <= after[3] + assert before[1] == after[1] + assert before[3] - after[3] == pytest.approx(7 if profile == Profile.COMPACT else 0) assert after[0] <= before[0] < before[2] <= after[2] first, last = slc_controls(profile, opened) scale = CONFIRMATION_SCALE[profile] @@ -60,12 +61,29 @@ def test_closed_and_open_card_share_anchor_and_physically_scaled_targets(profile assert (first.bounds[0] - after[0]) / scale == pytest.approx(8) assert (after[2] - last.bounds[2]) / scale == pytest.approx(8) torque = current.customization['layouts'][str(profile)]['torque_bar'] - assert after[3] < torque['y'] + assert max(before[3], after[3]) <= torque['y'] if profile == Profile.COMPACT: assert after == (148., 8, 476., 160.) assert after[0] > 146 +@pytest.mark.parametrize('profile', list(Profile)) +@pytest.mark.parametrize('wheel_confirmation', [False, True]) +def test_notice_footer_opens_choices_without_accepting_or_rejecting(profile, wheel_confirmation): + current = replace(state(), slc_wheel_confirmation_available=wheel_confirmation) + emitted = [] + touch = OnroadInput(emitted.append, profile) + bounds = speed_limit_bounds(profile, current) + assert bounds is not None + left, _, right, bottom = bounds + x, y = (left + right) / 2, bottom - 2 * CONFIRMATION_SCALE[profile] + touch.press(x, y, current) + touch.release(x, y, current) + assert touch.present(current).slc_prompt_open + assert len(slc_controls(profile, touch.present(current))) == 2 + assert not emitted + + @pytest.mark.parametrize('profile', list(Profile)) @pytest.mark.parametrize('lateral', [False, True]) def test_disengaged_and_aol_views_cancel_confirmation_but_keep_the_sign(profile, lateral): @@ -136,7 +154,7 @@ def test_new_layouts_reject_torque_overlap_and_legacy_migration_is_targeted(prof original = deepcopy(doc) migrated = validate_document(doc) assert doc == original - assert migrated['version'] == 13 + assert migrated['version'] == 14 assert migrated['palette'] == doc['palette'] assert migrated['layouts']['large']['current_speed'] == doc['layouts']['large']['current_speed'] assert migrated['layouts'][str(profile)][owner]['y'] == default_document()['layouts'][str(profile)][owner]['y'] diff --git a/openpilot/starpilot/ui/tests/test_slc_hud_contract.py b/openpilot/starpilot/ui/tests/test_slc_hud_contract.py index 8883e29a95..24a16c4802 100644 --- a/openpilot/starpilot/ui/tests/test_slc_hud_contract.py +++ b/openpilot/starpilot/ui/tests/test_slc_hud_contract.py @@ -63,7 +63,7 @@ def test_hidden_set_keeps_control_context_inside_moved_confirmation(profile, mod document = default_document() document['showMaxSpeed'] = False document['layouts']['compact']['max_speed']['enabled'] = False - document['layouts']['compact']['speed_limit'].update(x=200, y=20) + document['layouts']['compact']['speed_limit'].update(x=200, y=16) document = validate_document(document) current = state(customization=document, slc_prompt_open=True) current = replace(current, longitudinal_overridden=mode == 'pedal', speed_limit=replace(current.speed_limit, @@ -79,8 +79,8 @@ def test_hidden_set_keeps_control_context_inside_moved_confirmation(profile, mod assert not foreground_calls(font.draw) SpeedLimitWidget(font, profile).render_confirmation(current) rows = [call.args for call in foreground_calls(font.draw)] - expected = {'limit': 'Now: LIMIT 128 km/h', 'override': 'Now: OVERRIDE 128 km/h', 'pedal': 'Now: PEDAL'}[mode] - assert [row[0] for row in rows] == [expected, 'Accept', '120', 'km/h', 'Reject', 'new limit'] + expected = {'limit': 'Now: LIMIT 128', 'override': 'Now: OVERRIDE 128', 'pedal': 'Now: PEDAL'}[mode] + assert [row[0] for row in rows] == [expected, 'ACCEPT', '120', 'REJECT', 'NEW LIMIT'] status, accept = rows[:2] assert status[4] + font.vertical_ink(*status[:3])[1] < accept[4] assert not monitor_visible(Profile.COMPACT, current, fresh=True, onroad=True, top_icons=False) @@ -110,7 +110,7 @@ def test_displayed_proposal_matches_the_decision_during_a_held_override(profile, metric=True, longitudinal_active=True, slc_prompt_open=True) font = fonts(profile) SpeedLimitWidget(font, profile).render_confirmation(current) - assert [call.args[0] for call in foreground_calls(font.draw)] == ['Now: OVERRIDE 128 km/h', 'Accept', '120', 'km/h', 'Reject', 'new limit'] + assert [call.args[0] for call in foreground_calls(font.draw)] == ['Now: OVERRIDE 128', 'ACCEPT', '120', 'REJECT', 'NEW LIMIT'] assert resolve_unified_speed(current).override_text == '128' action = Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, choice) result = tick(runtime, sm, cp, 4, request=action).message.slcState @@ -142,7 +142,7 @@ def test_hidden_set_prompt_suppresses_only_overlapping_optional_text(widget, met monkeypatch.setattr('openpilot.starpilot.ui.onroad_weather.draw_weather_icon', Mock()) document = default_document() document['layouts']['compact']['max_speed']['enabled'] = False - document['layouts']['compact']['speed_limit'].update(x=200, y=20) + document['layouts']['compact']['speed_limit'].update(x=200, y=16) document['layouts']['compact'][widget].update(enabled=True, x=330 if widget == 'weather' else 200, y=0) current = state(customization=validate_document(document), slc_prompt_open=True) view = OnroadView.__new__(OnroadView) diff --git a/openpilot/starpilot/ui/tests/test_speed_stack.py b/openpilot/starpilot/ui/tests/test_speed_stack.py index 30839d73c4..7e546a4c4f 100644 --- a/openpilot/starpilot/ui/tests/test_speed_stack.py +++ b/openpilot/starpilot/ui/tests/test_speed_stack.py @@ -52,7 +52,7 @@ 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'] == 13 + assert migrated['version'] == 14 assert migrated['layouts']['large']['cruise_limits']['x'] == 1437 assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1678, 'y': 267, 'enabled': True} assert migrated['layouts']['large']['current_speed'] == old_speed diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_native.py b/openpilot/starpilot/ui/tests/test_unified_speed_native.py index 034a29562b..d1b596e164 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_native.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_native.py @@ -123,7 +123,7 @@ def test_set_and_binding_slc_stay_visible_beside_sign_or_prompt(profile, stage): texts = [call.args[0] for call in foreground_calls(font.draw)] assert 'SET' in texts and '35' in texts and not any(text.startswith('SLC ') for text in texts) assert ('LIMIT 30' in texts) == (stage == 'prompt') - assert ('Now: LIMIT 30 mph' in texts) == (stage == 'expanded') + assert ('Now: LIMIT 30' in texts) == (stage == 'expanded') assert ('Limit' in texts) == (stage in ('live', 'retained')) if pending: assert '25' in texts and not any(text.startswith('LAST') for text in texts) @@ -181,7 +181,7 @@ def test_sign_heading_identifies_the_displayed_source(profile, pending, vienna, font = fonts(profile) render_sign(SpeedLimitWidget(font, profile), current) expected = [heading, '50'] - assert [call.args[0] for call in foreground_calls(font.draw)] == expected + (['+5'] if offset else []) + (['New Limit', 'Tap to review'] if pending else []) + assert [call.args[0] for call in foreground_calls(font.draw)] == expected + (['+5'] if offset else []) + (['NEW LIMIT', 'Tap to review'] if pending else []) if not pending: label, speed = foreground_calls(font.draw)[:2] assert rgba_tuple(label.args[-1]) == rgba_tuple(speed.args[-1]) @@ -217,10 +217,10 @@ def test_pending_notice_pulses_without_changing_ink_and_arrow_follows_speed(prof assert drawing['arrow'].call_args.args[3] == -1 render_sign(sign, replace(current, observed_ns=10_350_000_000, speed_mps=60 / 3.6)) after = {call.args[0]: call.args for call in foreground_calls(font.draw)} - assert set(before) == {'OSM', '50', '+5', 'New Limit', 'Tap to review'} + assert set(before) == {'OSM', '50', '+5', 'NEW LIMIT', 'Tap to review'} assert all(after[text][3:5] == args[3:5] for text, args in before.items()) - assert all(rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items() if text != 'New Limit') - assert rgba_tuple(after['New Limit'][-1]) != rgba_tuple(before['New Limit'][-1]) + assert all(rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items() if text != 'NEW LIMIT') + assert rgba_tuple(after['NEW LIMIT'][-1]) != rgba_tuple(before['NEW LIMIT'][-1]) drawing['circle'].assert_not_called() drawing['outline'].assert_not_called() assert drawing['arrow'].call_args.args[3] == 1 @@ -248,12 +248,17 @@ def test_wheel_cues_follow_confirmation_availability(profile, available, drawing sign.render_confirmation(current) drawing['draw_rectangle_rounded_lines_ex'].assert_not_called() drawing['button'].assert_not_called() + texts = [call.args[0] for call in foreground_calls(sign.fonts.draw)] + assert 'NEW LIMIT' in texts + assert ('ACCEPT' in texts) == available + assert ('REJECT' in texts) == available calls = drawing['wheel_key'].call_args_list assert [call.args[0] for call in calls] == ([True, False] if available else []) if available: bounds = speed_limit_bounds(profile, current) assert bounds is not None - assert all(call.args[2] == pytest.approx(bounds[1] + 135.5 * CONFIRMATION_SCALE[profile]) for call in calls) + hint_y = bounds[1] + (12 if profile == Profile.LARGE else 8) + (142 if profile == Profile.LARGE else 136.75) * CONFIRMATION_SCALE[profile] + assert all(call.args[2] == pytest.approx(hint_y) for call in calls) assert bounds[0] < calls[0].args[1] < calls[1].args[1] < bounds[2] @@ -398,9 +403,11 @@ def test_compact_set_keeps_dinish_number_and_has_no_backplate(drawing): 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 foreground_calls(font.draw)] == [ - ('SET', FontRole.MEDIUM), ('km/h', FontRole.NORMAL), ('80', FontRole.SET_SPEED)] + ('SET', FontRole.SEMI_BOLD), ('80', FontRole.SET_SPEED)] assert font_filename(Profile.COMPACT, FontRole.SET_SPEED) == 'DINish-Speed.fnt' - assert foreground_calls(font.draw)[2].args[4] == 46 + number = foreground_calls(font.draw)[1].args + top, bottom = font.vertical_ink(*number[:3]) + assert number[4] + (top + bottom) / 2 == 84 drawing['draw_circle_gradient'].assert_not_called() @@ -416,16 +423,20 @@ def test_compact_unset_max_stays_hidden(metric, engaged, drawing): drawing['draw_circle_gradient'].assert_not_called() -def test_large_limit_keeps_a_unit_when_set_speed_is_hidden(): - profile = Profile.LARGE - current = state() - current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None)) +@pytest.mark.parametrize('profile', list(Profile)) +@pytest.mark.parametrize('metric', [False, True]) +def test_limit_has_no_unit_when_set_speed_is_hidden(profile, metric): + current = state(metric=metric) current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None)) current.customization['showMaxSpeed'] = False current.customization['layouts']['compact']['max_speed']['enabled'] = False font = fonts(profile) - UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current) - assert [call.args[0] for call in foreground_calls(font.draw)].count('km/h') == 1 + if profile == Profile.LARGE: + UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current) + else: + CompactHudRenderer(font, Mock()).render(current) + texts = [call.args[0] for call in foreground_calls(font.draw)] + assert texts and not any('mph' in text or 'km/h' in text for text in texts) @pytest.mark.parametrize('profile', list(Profile)) @@ -436,26 +447,26 @@ def test_transparent_chooser_keeps_touch_targets_and_pressed_feedback(profile, p controls = slc_controls(profile, current) view._slc_actions(current) rows = [call.args for call in foreground_calls(view.fonts.draw)] - assert [row[0] for row in rows] == ['New limit', 'Accept', '55', 'km/h', 'Reject', 'new limit'] + assert [row[0] for row in rows] == ['NEW LIMIT', 'ACCEPT', '55', 'REJECT', 'NEW LIMIT'] drawing['draw_rectangle_rounded'].assert_not_called() drawing['draw_line_ex'].assert_not_called() drawing['draw_rectangle_rounded_lines_ex'].assert_not_called() outlines = drawing['button'].call_args_list assert [rgba_tuple(call.args[-1]) for call in outlines] == [rgba_tuple(IN_USE), rgba_tuple(REJECT)] - for control, content in zip(controls, (rows[1:4], rows[4:]), strict=True): + for control, content in zip(controls, (rows[1:3], rows[3:]), strict=True): for row in content: left, top, right, bottom = control.bounds a, b = view.fonts.horizontal_ink(*row[:3]) c, d = view.fonts.vertical_ink(*row[:3]) assert left < row[3] + a < row[3] + b < right assert top < row[4] + c < row[4] + d < bottom - original = [rgba_tuple(row[-1]) for row in rows if row[0] in ('Accept', 'Reject')] + original = [rgba_tuple(row[-1]) for row in rows if row[0] in ('ACCEPT', 'REJECT')] view.pressed_action = lambda: controls[pressed_index].request view.fonts.draw.reset_mock() drawing['button'].reset_mock() view._slc_actions(current) assert [call.args[:5] for call in foreground_calls(view.fonts.draw)] == [row[:5] for row in rows] - pressed = [rgba_tuple(call.args[-1]) for call in foreground_calls(view.fonts.draw) if call.args[0] in ('Accept', 'Reject')] + pressed = [rgba_tuple(call.args[-1]) for call in foreground_calls(view.fonts.draw) if call.args[0] in ('ACCEPT', 'REJECT')] assert pressed[pressed_index] != original[pressed_index] and pressed[1-pressed_index] == original[1-pressed_index] pressed_outlines = drawing['button'].call_args_list assert [call.kwargs['pressed'] for call in pressed_outlines] == [pressed_index == 0, pressed_index == 1]