Restore C4 MAX fade; contain SLC offsets for readability

This commit is contained in:
firestarsdog
2026-10-21 23:06:03 -04:00
parent 9ab16c0838
commit a5f18d35cd
17 changed files with 356 additions and 108 deletions
@@ -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 = []
@@ -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] = {
@@ -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)
@@ -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)) ||
@@ -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')
@@ -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):
@@ -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
+30 -19
View File
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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))
@@ -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()
@@ -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)
@@ -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)
@@ -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