From 7e76fa9b4169a16e210b02d36711b4fc85bb01a3 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sat, 4 Jul 2026 07:54:48 -0400 Subject: [PATCH] BigUI WIP: + SLC --- .../layouts/settings/starpilot/appearance.py | 9 +- .../settings/starpilot/longitudinal.py | 102 +++- .../ui/onroad/starpilot/slc_speed_limit.py | 461 +++++++++++------- 3 files changed, 377 insertions(+), 195 deletions(-) diff --git a/selfdrive/ui/layouts/settings/starpilot/appearance.py b/selfdrive/ui/layouts/settings/starpilot/appearance.py index b06ba9d2f..99c032e9d 100644 --- a/selfdrive/ui/layouts/settings/starpilot/appearance.py +++ b/selfdrive/ui/layouts/settings/starpilot/appearance.py @@ -365,7 +365,8 @@ class StarPilotAppearanceLayout(_SettingsPage): SettingRow("HideSpeedLimit", "toggle", tr_noop("Hide Speed Limit"), subtitle="", get_state=lambda: self._params.get_bool("HideSpeedLimit"), - set_state=lambda s: self._params.put_bool("HideSpeedLimit", s)), + set_state=lambda s: self._params.put_bool("HideSpeedLimit", s), + visible=lambda: ol() and self._params.get_bool("SpeedLimitController")), SettingRow("HideAlerts", "toggle", tr_noop("Hide Alerts"), subtitle="", get_state=lambda: self._params.get_bool("HideAlerts"), @@ -414,11 +415,13 @@ class StarPilotAppearanceLayout(_SettingsPage): SettingRow("ShowSpeedLimits", "toggle", tr_noop("Speed Limits"), subtitle="", get_state=lambda: self._params.get_bool("ShowSpeedLimits"), - set_state=lambda s: self._params.put_bool("ShowSpeedLimits", s)), + set_state=lambda s: self._params.put_bool("ShowSpeedLimits", s), + visible=lambda: not (self._params.get_bool("SpeedLimitController") and ol())), SettingRow("UseVienna", "toggle", tr_noop("Vienna Signs"), subtitle="", get_state=lambda: self._params.get_bool("UseVienna"), - set_state=lambda s: self._params.put_bool("UseVienna", s)), + set_state=lambda s: self._params.put_bool("UseVienna", s), + visible=lambda: self._params.get_bool("ShowSpeedLimits") or self._params.get_bool("SpeedLimitController")), SettingRow("QOLVisuals", "toggle", tr_noop("Quality of Life"), subtitle=tr_noop("Convenience features for everyday driving."), get_state=lambda: self._params.get_bool("QOLVisuals"), diff --git a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py index 55b28788a..f94850239 100644 --- a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py +++ b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py @@ -45,6 +45,7 @@ from openpilot.starpilot.common.accel_profile import ( normalize_deceleration_profile, ) from openpilot.starpilot.common.experimental_state import sync_persist_experimental_state, sync_persist_chill_state +from openpilot.common.constants import CV PANEL_STYLE = DEFAULT_PANEL_STYLE @@ -547,7 +548,11 @@ class StarPilotLongitudinalLayout(_SettingsPage): def __init__(self): super().__init__() self._keyboard = Keyboard(min_text_size=1) + # Track metric↔imperial transitions so we can rescale speed- and + # distance-typed params to the new unit when it flips. + self._last_is_metric: bool | None = None self._build_view() + self._last_is_metric = self._is_metric() def _longitudinal_enabled(self) -> bool: return self._params.get_bool("LongitudinalTune") @@ -710,10 +715,11 @@ class StarPilotLongitudinalLayout(_SettingsPage): subtitle="", get_value=lambda: f"{self._params.get_int('SLCLookaheadLower')}s", on_click=lambda: self._show_slider("SLCLookaheadLower", 0, 30, unit="s")), - SettingRow("SLCMapbox", "toggle", tr_noop("Mapbox Fallback"), + SettingRow("SLCMapboxFiller", "toggle", tr_noop("Mapbox Fallback"), subtitle="", get_state=lambda: self._params.get_bool("SLCMapboxFiller"), - set_state=lambda s: self._params.put_bool("SLCMapboxFiller", s)), + set_state=lambda s: self._params.put_bool("SLCMapboxFiller", s), + visible=self._mapbox_available), SettingRow("VisionSpeedLimit", "toggle", tr_noop("Vision Detection"), subtitle=tr_noop("Use the road camera to detect speed limit signs for SLC."), get_state=lambda: self._params.get_bool("VisionSpeedLimitDetection"), @@ -722,10 +728,20 @@ class StarPilotLongitudinalLayout(_SettingsPage): subtitle="", get_state=lambda: self._params.get_bool("ShowSLCOffset"), set_state=lambda s: self._params.put_bool("ShowSLCOffset", s)), - SettingRow("ShowSources", "toggle", tr_noop("Show Sources"), + SettingRow("SpeedLimitSources", "toggle", tr_noop("Show Sources"), subtitle="", get_state=lambda: self._params.get_bool("SpeedLimitSources"), set_state=lambda s: self._params.put_bool("SpeedLimitSources", s)), + SettingRow("SLCAbbreviatedSources", "toggle", tr_noop("Abbreviated Sources"), + subtitle=tr_noop("Render speed-limit sources as compact text labels (e.g. Dash-45)."), + get_state=lambda: self._params.get_bool("SLCAbbreviatedSources"), + set_state=lambda s: self._params.put_bool("SLCAbbreviatedSources", s), + visible=self._sources_visible), + SettingRow("SLCActiveSourcesOnly", "toggle", tr_noop("Active Sources Only"), + subtitle=tr_noop("Hide source rows that have no current speed limit reading."), + get_state=lambda: self._params.get_bool("SLCActiveSourcesOnly"), + set_state=lambda s: self._params.put_bool("SLCActiveSourcesOnly", s), + visible=self._sources_visible), SettingRow("ConfigureOffsets", "value", tr_noop("SLC Offsets"), subtitle=tr_noop("Per-limit speed adjustments for the Speed Limit Controller."), get_value=lambda: tr_noop("Configure"), @@ -792,14 +808,16 @@ class StarPilotLongitudinalLayout(_SettingsPage): self._daily_rows = [ SettingRow("CustomCruise", "value", tr_noop("Cruise Interval"), subtitle="", - get_value=lambda: f"{max(1, self._params.get_int('CustomCruise'))} mph", - on_click=lambda: self._show_slider("CustomCruise", 1, 100, unit=" mph", + get_value=lambda: f"{max(1, self._params.get_int('CustomCruise'))}{self._speed_unit()}", + on_click=lambda: self._show_slider("CustomCruise", 1, 150 if self._is_metric() else 99, + unit=self._speed_unit(), current_value=max(1, self._params.get_int("CustomCruise"))), visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("CustomCruiseLong", "value", tr_noop("Cruise Long"), subtitle="", - get_value=lambda: f"{max(1, self._params.get_int('CustomCruiseLong'))} mph", - on_click=lambda: self._show_slider("CustomCruiseLong", 1, 100, unit=" mph", + get_value=lambda: f"{max(1, self._params.get_int('CustomCruiseLong'))}{self._speed_unit()}", + on_click=lambda: self._show_slider("CustomCruiseLong", 1, 150 if self._is_metric() else 99, + unit=self._speed_unit(), current_value=max(1, self._params.get_int("CustomCruiseLong"))), visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("ForceStops", "toggle", tr_noop("Force Stops"), @@ -824,13 +842,15 @@ class StarPilotLongitudinalLayout(_SettingsPage): visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("IncStoppedDist", "value", tr_noop("Stopped Distance"), subtitle="", - get_value=lambda: f"{self._params.get_int('IncreasedStoppedDistance')} ft", - on_click=lambda: self._show_slider("IncreasedStoppedDistance", 0, 10, unit=" ft"), + get_value=lambda: f"{self._params.get_int('IncreasedStoppedDistance')}{self._distance_unit()}", + on_click=lambda: self._show_slider("IncreasedStoppedDistance", *self._distance_range(), + unit=self._distance_unit()), visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("SetSpeedOffset", "value", tr_noop("Set Speed Offset"), subtitle="", - get_value=lambda: f"+{self._params.get_int('SetSpeedOffset')} mph", - on_click=lambda: self._show_slider("SetSpeedOffset", 0, 99, unit=" mph"), + get_value=lambda: f"+{self._params.get_int('SetSpeedOffset')}{self._speed_unit()}", + on_click=lambda: self._show_slider("SetSpeedOffset", 0, 150 if self._is_metric() else 99, + unit=self._speed_unit()), visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("MapGears", "toggle", tr_noop("Map Gears"), subtitle="", @@ -1079,12 +1099,67 @@ class StarPilotLongitudinalLayout(_SettingsPage): def _is_metric(self) -> bool: return self._params.get_bool("IsMetric") + # Speed-typed int params stored in the current unit (mph or km/h); rescaled + # when IsMetric flips so the numeric value stays correct in the new unit. + _SPEED_RESCALE_KEYS = ( + "Offset1", "Offset2", "Offset3", "Offset4", "Offset5", "Offset6", "Offset7", + "CustomCruise", "CustomCruiseLong", "SetSpeedOffset", + "CESpeed", "CESpeedLead", "CESignalSpeed", + "CCMSpeed", "CCMSpeedLead", "CCMSetSpeedMargin", + ) + + # Distance-typed int params stored in the current unit (ft or m); rescaled + # when IsMetric flips so the numeric value stays correct in the new unit. + _DISTANCE_RESCALE_KEYS = ( + "IncreasedStoppedDistance", + "IncreasedStoppedDistanceLowVisibility", + "IncreasedStoppedDistanceRain", + "IncreasedStoppedDistanceRainStorm", + "IncreasedStoppedDistanceSnow", + ) + + def _maybe_rescale_on_metric_change(self) -> None: + """When IsMetric flips, recompute stored speed- and distance-typed int + params so they read correctly in the new unit. The first call (no prior + state) is a no-op so the user's saved values aren't rewritten on boot.""" + current = self._is_metric() + last = self._last_is_metric + self._last_is_metric = current + if last is None or last == current: + return + speed_factor = CV.MPH_TO_KPH if current else CV.KPH_TO_MPH + for key in self._SPEED_RESCALE_KEYS: + self._params.put_int(key, int(round(self._params.get_int(key) * speed_factor))) + distance_factor = CV.FOOT_TO_METER if current else CV.METER_TO_FOOT + for key in self._DISTANCE_RESCALE_KEYS: + self._params.put_int(key, int(round(self._params.get_int(key) * distance_factor))) + + def _mapbox_available(self) -> bool: + """SLCMapboxFiller is only meaningful when a Mapbox key is configured.""" + return bool(self._params.get("MapboxSecretKey", encoding="utf-8")) + + def _sources_visible(self) -> bool: + """Abbreviated/Active-Only sub-toggles only make sense when sources are shown.""" + return self._params.get_bool("SpeedLimitSources") + def _speed_unit(self) -> str: + self._maybe_rescale_on_metric_change() return " km/h" if self._is_metric() else " mph" def _speed_range(self) -> tuple[int, int]: + self._maybe_rescale_on_metric_change() return (-150, 150) if self._is_metric() else (-99, 99) + # IncreasedStoppedDistance* sliders use 0..3 m in metric and 0..10 ft in + # imperial, reflecting the unit they're stored in. + def _distance_unit(self) -> str: + self._maybe_rescale_on_metric_change() + return " m" if self._is_metric() else " ft" + + def _distance_range(self) -> tuple[int, int]: + self._maybe_rescale_on_metric_change() + return (0, 3) if self._is_metric() else (0, 10) + def _show_slc_offsets_category(self): self._sub_panels["slc_offsets"] = AetherSettingsView( self, @@ -1177,8 +1252,9 @@ class StarPilotLongitudinalLayout(_SettingsPage): on_click=lambda: self._show_slider("IncreaseFollowing" + s, 0, 3, step=0.5, unit="s")), SettingRow(f"StoppedDist{s}", "value", tr_noop("Stopped Distance"), subtitle="", - get_value=lambda: f"+{self._params.get_int('IncreasedStoppedDistance' + s)} ft", - on_click=lambda: self._show_slider("IncreasedStoppedDistance" + s, 0, 10, unit=" ft")), + get_value=lambda: f"+{self._params.get_int('IncreasedStoppedDistance' + s)}{self._distance_unit()}", + on_click=lambda: self._show_slider("IncreasedStoppedDistance" + s, *self._distance_range(), + unit=self._distance_unit())), SettingRow(f"ReduceAccel{s}", "value", tr_noop("Reduce Accel"), subtitle="", get_value=lambda: f"{self._params.get_int('ReduceAcceleration' + s)}%", diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py index 58271dee1..c8628356a 100644 --- a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -1,3 +1,5 @@ +import math + import pyray as rl from openpilot.common.constants import CV from openpilot.selfdrive.ui import UI_BORDER_SIZE @@ -6,7 +8,7 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached -# ── Constants (matching Qt exactly) ─────────────────────────────────── +# ── Constants ───────────────────────────────────────────────────────── # Set speed rect layout (from hud_renderer.py UI_CONFIG) SET_SPEED_X_OFFSET = 60 @@ -17,7 +19,6 @@ SET_SPEED_WIDTH_MET = 200 # US MUTCD sign US_SIGN_HEIGHT = 186 -US_SIGN_RADIUS = 24 US_INNER_RADIUS = 16 US_BORDER_WIDTH = 6 US_INSET = 9 @@ -27,15 +28,23 @@ EU_SIGN_SIZE = 176 EU_SIGN_WIDTH = 176 RED_RING_WIDTH = 20 -# Pending sign +# Pending sign blink cadence — 1s period, 50% duty cycle. PENDING_BLINK_MS = 500 -PENDING_US_WIDE = 200 # Sources panel SOURCE_ROW_W = 450 SOURCE_ROW_H = 60 SOURCE_ROW_GAP = UI_BORDER_SIZE // 2 +# Source display metadata: title, abbreviation, raw-value key (display order). +SOURCE_DEFS = [ + ("Dashboard", "Dash", "dashboard_sl"), + ("Map Data", "MapD", "map_sl"), + ("Vision", "Vis", "vision_sl"), + ("Mapbox", "MapB", "mapbox_sl"), + ("Upcoming", "Next", "next_sl"), +] + # Fonts FONT_LABEL = 28 FONT_SPEED = 70 @@ -47,13 +56,76 @@ FONT_EU_OFFSET = 40 # Layout SIGN_MARGIN = 12 +# Vision speed-limit pulse — one-shot purple highlight when the active source +# is "Vision" and the resolved value just changed. +VISION_SPEED_LIMIT_PULSE_SECONDS = 1.0 +VISION_SPEED_LIMIT_PULSE_COLOR = rl.Color(188, 132, 255, 255) +VISION_SPEED_LIMIT_CHANGE_THRESHOLD = 0.1 # m/s + + +# ── Vision speed-limit pulse state (one-shot highlight) ──────────────── +# Persists across frames so we can detect a just-changed vision limit and +# animate the sign colors toward VISION_SPEED_LIMIT_PULSE_COLOR for +# VISION_SPEED_LIMIT_PULSE_SECONDS. Held at module scope because this file +# is a procedural API consumed once per frame by StarPilotOnroadView. + +_pulse = { + "active": False, # is the active source currently "Vision"? + "last": 0.0, # last resolved vision limit (m/s) seen while active + "start": -VISION_SPEED_LIMIT_PULSE_SECONDS, # get_time() stamp of the last change +} + + +def _reset_pulse() -> None: + """Clear pulse state when SLC goes hidden or stale.""" + _pulse["active"] = False + _pulse["last"] = 0.0 + _pulse["start"] = -VISION_SPEED_LIMIT_PULSE_SECONDS + + +def _tick_pulse(source: str, resolved_ms: float) -> None: + """Update pulse state once per frame from the resolved speed limit. + + The pulse fires when the active source is "Vision" and either the source + just became active or the resolved value changed by at least + VISION_SPEED_LIMIT_CHANGE_THRESHOLD (m/s). + """ + vision_active = source == "Vision" and resolved_ms > 0.0 + if vision_active and (not _pulse["active"] or abs(resolved_ms - _pulse["last"]) >= VISION_SPEED_LIMIT_CHANGE_THRESHOLD): + _pulse["start"] = rl.get_time() + _pulse["active"] = vision_active + _pulse["last"] = resolved_ms if vision_active else 0.0 + + +def _speed_limit_pulse_color(base: rl.Color, alpha: int) -> rl.Color: + """Blend ``base`` toward VISION_SPEED_LIMIT_PULSE_COLOR with a sin(pi*t) ease. + + Returns ``base`` unchanged (with the supplied alpha) outside the pulse + window. Inside it, r/g/b are eased toward the pulse color along sin(pi*t) + where t is elapsed / VISION_SPEED_LIMIT_PULSE_SECONDS. + """ + base_with_alpha = rl.Color(base.r, base.g, base.b, alpha) + elapsed = rl.get_time() - _pulse["start"] + if elapsed < 0.0 or elapsed >= VISION_SPEED_LIMIT_PULSE_SECONDS: + return base_with_alpha + + progress = elapsed / VISION_SPEED_LIMIT_PULSE_SECONDS + pulse = math.sin(math.pi * progress) + return rl.Color( + round(base.r + (VISION_SPEED_LIMIT_PULSE_COLOR.r - base.r) * pulse), + round(base.g + (VISION_SPEED_LIMIT_PULSE_COLOR.g - base.g) * pulse), + round(base.b + (VISION_SPEED_LIMIT_PULSE_COLOR.b - base.b) * pulse), + alpha, + ) + # ── State ───────────────────────────────────────────────────────────── def _get_slc_state(): - """Extract SLC state from SubMaster. Returns dict or None if stale.""" + """Extract SLC state from SubMaster. Returns dict or None if stale/hidden.""" sm = ui_state.sm if sm.recv_frame["starpilotPlan"] < ui_state.started_frame: + _reset_pulse() return None plan = sm["starpilotPlan"] @@ -61,9 +133,12 @@ def _get_slc_state(): show_slc = ui_state.params.get_bool("ShowSpeedLimits") or ui_state.params.get_bool("SpeedLimitController") hide_sl = ui_state.params.get_bool("HideSpeedLimit") - hide = not speed_limit_changed and hide_sl + unconfirmed_valid = plan.unconfirmedSlcSpeedLimit > 1 + # A pending (unconfirmed) limit overrides HideSpeedLimit so the prompt always shows. + hide = not (speed_limit_changed and unconfirmed_valid) and hide_sl if not show_slc and not speed_limit_changed: + _reset_pulse() return None speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH @@ -73,9 +148,16 @@ def _get_slc_state(): vision_sl = ui_state.params_memory.get_float("VisionSpeedLimit") if ui_state.params.get_bool("VisionSpeedLimitDetection") else 0.0 slc_overridden_speed = plan.slcOverriddenSpeed - speed_limit = plan.slcSpeedLimit + # Driver override takes precedence over the planner's limit when active. + speed_limit = slc_overridden_speed if slc_overridden_speed != 0 else plan.slcSpeedLimit - # Offset calculation (matches Qt lines 199-204) + # Resolved limit in m/s (pre-conversion, pre-offset) — feeds the vision pulse + # change detector so the comparison is unit-stable across km/h ↔ mph flips. + resolved_ms = speed_limit + + # Add the per-limit offset to the displayed value only when NOT overridden + # AND ShowSLCOffset is off (when the offset toggle is on, it's rendered as + # a separate field below the speed number instead). if slc_overridden_speed == 0 and not show_offset: speed_limit += plan.slcSpeedLimitOffset speed_limit *= speed_conversion @@ -83,12 +165,17 @@ def _get_slc_state(): speed_limit_offset = plan.slcSpeedLimitOffset * speed_conversion offset_str = f"{'+' if speed_limit_offset > 0 else '-'}{abs(int(round(speed_limit_offset)))}" if speed_limit_offset != 0 else "\u2013" + # Update the vision-source pulse once per frame, after resolved_ms is known + # and before any sign colors are computed downstream. + _tick_pulse(plan.slcSpeedLimitSource, resolved_ms) + return { 'speed_limit': speed_limit, 'speed_limit_str': "\u2013" if speed_limit <= 1 else str(int(round(speed_limit))), 'slc_overridden_speed': slc_overridden_speed, 'speed_limit_source': plan.slcSpeedLimitSource, 'unconfirmed_speed_limit': max(0.0, plan.unconfirmedSlcSpeedLimit * speed_conversion), + 'unconfirmed_valid': unconfirmed_valid, 'speed_limit_changed': speed_limit_changed, 'hide': hide, 'show_offset': show_offset, @@ -96,6 +183,9 @@ def _get_slc_state(): 'offset_str': offset_str, 'speed_conversion': speed_conversion, 'show_sources': ui_state.params.get_bool("SpeedLimitSources"), + 'abbreviated': ui_state.params.get_bool("SLCAbbreviatedSources"), + 'active_only': ui_state.params.get_bool("SLCActiveSourcesOnly"), + 'speed_unit': " km/h" if ui_state.is_metric else " mph", # Per-source raw values 'dashboard_sl': max(0.0, dashboard_sl * speed_conversion), 'map_sl': max(0.0, plan.slcMapSpeedLimit * speed_conversion), @@ -126,26 +216,50 @@ def _get_semi_bold(): # ── US MUTCD Sign ───────────────────────────────────────────────────── def _draw_us_sign(x: float, y: float, sign_width: float, speed_text: str, offset_str: str, - alpha: int, show_offset: bool): - """Draw US-style speed limit sign at (x, y) with given width.""" - sign_rect = rl.Rectangle(x, y, sign_width, US_SIGN_HEIGHT) + alpha: int, show_offset: bool, *, pending: bool = False): + """Draw US-style speed limit sign at (x, y) with the given width. + + Transparent panel (the road shows through) drawn with only an inset + white-on-transparent outline border and white text. Border and text colors + are routed through _speed_limit_pulse_color so a freshly-changed Vision + limit pulses toward VISION_SPEED_LIMIT_PULSE_COLOR for + VISION_SPEED_LIMIT_PULSE_SECONDS. The pending-state blink (white <-> red + border) composes with that pulse. ``alpha`` is the sign-wide opacity + (e.g. 72 when the driver has manually overridden the limit, 255 otherwise). + """ + # Pending: border blinks white <-> red. Active: border is white. + if pending: + blink_on = int(rl.get_time() * 1000) % 1000 < PENDING_BLINK_MS + base_border = rl.Color(255, 255, 255, alpha) if blink_on else rl.Color(201, 34, 49, alpha) + else: + base_border = rl.Color(255, 255, 255, alpha) + + # Compose the blink base with the active vision pulse (no-op outside window). + border_color = _speed_limit_pulse_color(base_border, alpha) + # White text reads on the transparent background, matching the border color. + text_color = _speed_limit_pulse_color(rl.Color(255, 255, 255, 255), alpha) + + # No solid fill — the sign is an outline-only overlay on the road. + # Inset outline border; thinner than the outer radius for visual separation. border_rect = rl.Rectangle(x + US_INSET, y + US_INSET, sign_width - 2 * US_INSET, US_SIGN_HEIGHT - 2 * US_INSET) - - # White background - rl.draw_rectangle_rounded(sign_rect, US_SIGN_RADIUS / US_SIGN_HEIGHT, 16, - rl.Color(255, 255, 255, alpha)) - # Black border rl.draw_rectangle_rounded_lines_ex(border_rect, US_INNER_RADIUS / (US_SIGN_HEIGHT - 18), 16, - US_BORDER_WIDTH, rl.Color(0, 0, 0, alpha)) + max(US_BORDER_WIDTH - 2, 1), border_color) font_bold = _get_bold() font_semi = _get_semi_bold() - text_color = rl.Color(0, 0, 0, alpha) cx = x + sign_width / 2 - if show_offset: + # Pending layout: "PENDING" + "LIMIT" + speed (no offset shown when pending). + if pending: + pending_size = measure_text_cached(font_semi, tr("PENDING"), FONT_LABEL) + rl.draw_text_ex(font_semi, tr("PENDING"), rl.Vector2(cx - pending_size.x / 2, y + 22), FONT_LABEL, 0, text_color) + limit_size = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL) + rl.draw_text_ex(font_semi, tr("LIMIT"), rl.Vector2(cx - limit_size.x / 2, y + 51), FONT_LABEL, 0, text_color) + speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED) + rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 85), FONT_SPEED, 0, text_color) + elif show_offset: # Offset ON: "LIMIT" at y=22, speed at y=51, offset at y=120 limit_size = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL) rl.draw_text_ex(font_semi, tr("LIMIT"), rl.Vector2(cx - limit_size.x / 2, y + 22), FONT_LABEL, 0, text_color) @@ -170,140 +284,147 @@ def _draw_us_sign(x: float, y: float, sign_width: float, speed_text: str, offset # ── EU Vienna Sign ──────────────────────────────────────────────────── def _draw_eu_sign(x: float, y: float, speed_text: str, offset_str: str, - text_alpha: int, show_offset: bool): - """Draw EU-style speed limit sign at (x, y).""" + text_alpha: int, show_offset: bool, *, pending: bool = False): + """Draw EU-style (Vienna) speed limit sign at (x, y). + + White disk with a pulsable red ring and pulsable black text. The disk + fill, ring, and text all carry the sign-wide ``text_alpha`` (e.g. 72 when + driver-overridden, 255 otherwise), so the road shows through when dimmed + without losing legibility. The pre-existing pending-text blink + (black <-> red) composes with the vision pulse: outside the pulse window + the blink is unchanged, inside it both colors are eased toward + VISION_SPEED_LIMIT_PULSE_COLOR. + """ center_x = x + EU_SIGN_SIZE / 2 center_y = y + EU_SIGN_SIZE / 2 radius = EU_SIGN_SIZE / 2 - # White circle (full opacity) - rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, 255)) - # Red ring (full opacity, static) + # White disk fill; alpha-dims with the sign so an overridden limit fades + # against the road. + rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, text_alpha)) + # Red ring; eased toward VISION_SPEED_LIMIT_PULSE_COLOR when a Vision-sourced + # limit just changed, and alpha-dims with the sign. + ring_color = _speed_limit_pulse_color(rl.Color(201, 34, 49, 255), text_alpha) rl.draw_ring(rl.Vector2(center_x, center_y), radius - RED_RING_WIDTH, radius, - 0, 360, 64, rl.Color(201, 34, 49, 255)) + 0, 360, 64, ring_color) font_bold = _get_bold() - font_semi = _get_semi_bold() - text_color = rl.Color(0, 0, 0, text_alpha) eu_font = FONT_EU_LARGE if len(speed_text) <= 2 else FONT_EU_SMALL - if show_offset: + # EU pending: text blinks black/red, composed with the vision pulse. + if pending: + blink_on = int(rl.get_time() * 1000) % 1000 < PENDING_BLINK_MS + base_text = rl.Color(0, 0, 0, 255) if blink_on else rl.Color(201, 34, 49, 255) + else: + base_text = rl.Color(0, 0, 0, 255) + text_color = _speed_limit_pulse_color(base_text, text_alpha) + + # Pending: text centered (no offset display) + if pending or not show_offset: + speed_size = measure_text_cached(font_bold, speed_text, eu_font) + speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2) + rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color) + else: # Offset ON: speed shifted up, offset below speed_size = measure_text_cached(font_bold, speed_text, eu_font) speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2 - 25) rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color) + font_semi = _get_semi_bold() offset_size = measure_text_cached(font_semi, offset_str, FONT_EU_OFFSET) offset_pos = rl.Vector2(center_x - offset_size.x / 2, y + 100) rl.draw_text_ex(font_semi, offset_str, offset_pos, FONT_EU_OFFSET, 0, text_color) + + +# ── Dispatcher (pending and active sign share the same rect) ───────── + +def _draw_sign(state: dict, rect: rl.Rectangle, *, pending: bool = False): + """Draw either the pending or active sign in the given rect.""" + if pending: + # Pending shows the unconfirmed value, full opacity + speed_text = ("\u2013" if state['unconfirmed_speed_limit'] <= 1 + else str(int(round(state['unconfirmed_speed_limit'])))) + text_alpha = 255 else: - # Centered speed value - speed_size = measure_text_cached(font_bold, speed_text, eu_font) - speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2) - rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color) - - -# ── Main Speed Limit Sign ──────────────────────────────────────────── - -def _draw_speed_limit_sign(state: dict, sign_x: float, sign_y: float, sign_width: float): - """Draw the main speed limit sign (US or EU based on setting).""" - speed_text = state['speed_limit_str'] - offset_str = state['offset_str'] - alpha = 72 if state['slc_overridden_speed'] != 0 else 255 + speed_text = state['speed_limit_str'] + # Override dim: when the driver has manually overridden the speed limit, + # fade the sign to alpha=72 to indicate it's no longer the auto-detected + # value. + text_alpha = 72 if state['slc_overridden_speed'] != 0 else 255 if state['use_vienna']: - _draw_eu_sign(sign_x, sign_y, speed_text, offset_str, alpha, state['show_offset']) + _draw_eu_sign(rect.x, rect.y, speed_text, state['offset_str'], text_alpha, + state['show_offset'], pending=pending) else: - _draw_us_sign(sign_x, sign_y, sign_width, speed_text, offset_str, alpha, state['show_offset']) + _draw_us_sign(rect.x, rect.y, rect.width, speed_text, state['offset_str'], + text_alpha, state['show_offset'], pending=pending) -# ── Pending Speed Limit Sign ───────────────────────────────────────── +# ── Active Source Label (single-line mode when sources panel is off) ── -def _draw_pending_sign(state: dict, pending_x: float, pending_y: float, sign_width: float): - """Draw the blinking pending speed limit sign.""" - pending_speed = state['unconfirmed_speed_limit'] - if pending_speed <= 0: +_SOURCE_ABBREV = {"Dashboard": "DASH", "Map Data": "MAPS", "Vision": "VISION", + "Mapbox": "MAPB", "Upcoming": "NAV"} + +def _draw_active_source_label(state: dict, cx: float, bottom_y: float): + """Draw the single active-source indicator below the sign when sources panel is off.""" + source = state.get('speed_limit_source') + if not source or source == "None" or source == "": return - - speed_text = "\u2013" if pending_speed <= 1 else str(int(round(pending_speed))) - use_vienna = state['use_vienna'] - blink_on = int(rl.get_time() * 1000) % (PENDING_BLINK_MS * 2) < PENDING_BLINK_MS - - if use_vienna: - # EU pending: white circle, STATIC red ring, TEXT blinks black/red - size = EU_SIGN_SIZE - center_x = pending_x + size / 2 - center_y = pending_y + size / 2 - radius = size / 2 - - rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, 255)) - rl.draw_ring(rl.Vector2(center_x, center_y), radius - RED_RING_WIDTH, radius, - 0, 360, 64, rl.Color(201, 34, 49, 255)) - - eu_font = FONT_EU_LARGE if len(speed_text) <= 2 else FONT_EU_SMALL - text_color = rl.Color(0, 0, 0) if blink_on else rl.Color(201, 34, 49) - font_bold = _get_bold() - speed_size = measure_text_cached(font_bold, speed_text, eu_font) - speed_pos = rl.Vector2(center_x - speed_size.x / 2, center_y - speed_size.y / 2) - rl.draw_text_ex(font_bold, speed_text, speed_pos, eu_font, 0, text_color) - - else: - # US pending: white rounded rect, BORDER blinks black/red - w = PENDING_US_WIDE if len(speed_text) >= 3 else sign_width - h = US_SIGN_HEIGHT - sign_rect = rl.Rectangle(pending_x, pending_y, w, h) - border_rect = rl.Rectangle(pending_x + US_INSET, pending_y + US_INSET, - w - 2 * US_INSET, h - 2 * US_INSET) - - rl.draw_rectangle_rounded(sign_rect, US_SIGN_RADIUS / h, 16, rl.Color(255, 255, 255, 255)) - - border_color = rl.Color(0, 0, 0) if blink_on else rl.Color(201, 34, 49) - rl.draw_rectangle_rounded_lines_ex(border_rect, US_INNER_RADIUS / (h - 18), 16, - US_BORDER_WIDTH, border_color) - - font_bold = _get_bold() - font_semi = _get_semi_bold() - black = rl.Color(0, 0, 0, 255) - cx = pending_x + w / 2 - - pending_label = measure_text_cached(font_semi, tr("PENDING"), FONT_LABEL) - rl.draw_text_ex(font_semi, tr("PENDING"), - rl.Vector2(cx - pending_label.x / 2, pending_y + 22), - FONT_LABEL, 0, black) - - limit_label = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL) - rl.draw_text_ex(font_semi, tr("LIMIT"), - rl.Vector2(cx - limit_label.x / 2, pending_y + 51), - FONT_LABEL, 0, black) - - speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED) - rl.draw_text_ex(font_bold, speed_text, - rl.Vector2(cx - speed_size.x / 2, pending_y + 85), - FONT_SPEED, 0, black) + label = _SOURCE_ABBREV.get(source, source.upper()) + font = _get_semi_bold() + font_size = 20 + sz = measure_text_cached(font, label, font_size) + rect = rl.Rectangle(cx - sz.x / 2 - 8, bottom_y + 8, sz.x + 16, font_size + 8) + rl.draw_rectangle_rounded(rect, 0.4, 8, rl.Color(0, 0, 0, 180)) + rl.draw_rectangle_rounded_lines_ex(rect, 0.4, 8, 1, rl.Color(255, 255, 255, 100)) + rl.draw_text_ex(font, label, rl.Vector2(cx - sz.x / 2, bottom_y + 12), font_size, 0, rl.WHITE) # ── Sources Panel ───────────────────────────────────────────────────── -def _draw_sources_panel(state: dict, panel_x: float, panel_y: float): - """Draw the 5 source indicator rows below the speed limit sign.""" +def _draw_text_outlined(font, text: str, pos: rl.Vector2, font_size: int, fill: rl.Color, outline: rl.Color): + """Draw text with a black outline stroke for legibility on colored fills.""" + for dx in (-2, -1, 1, 2): + for dy in (-2, -1, 1, 2): + rl.draw_text_ex(font, text, rl.Vector2(pos.x + dx, pos.y + dy), font_size, 0, outline) + rl.draw_text_ex(font, text, pos, font_size, 0, fill) + + +def _draw_sources_panel(state: dict, panel_x: float, panel_y: float, sign_width: float): + """Draw the source indicator rows below the speed limit sign.""" font_bold = _get_bold() font_semi = _get_semi_bold() active_source = state['speed_limit_source'] + abbreviated = state['abbreviated'] + active_only = state['active_only'] + speed_unit = state['speed_unit'] - sources = [ - ("Dashboard", state['dashboard_sl']), - ("Map Data", state['map_sl']), - ("Vision", state['vision_sl']), - ("Mapbox", state['mapbox_sl']), - ("Upcoming", state['next_sl']), - ] + # Rect width: abbreviated mode sizes to content; full mode uses SOURCE_ROW_W. + rect_w = sign_width if abbreviated else SOURCE_ROW_W + + if abbreviated: + # Pre-compute the widest visible row label so all boxes share the same width. + max_w = rect_w + for _title, abbrev, value_key in SOURCE_DEFS: + value = state[value_key] + if active_only and value == 0: + continue + label = f"{abbrev}-{int(round(value))}" if value != 0 else f"{abbrev}-na" + needed = measure_text_cached(font_semi, label, 35).x + 40 + if needed > max_w: + max_w = needed + rect_w = max_w y = panel_y - for title, value in sources: + for title, abbrev, value_key in SOURCE_DEFS: + value = state[value_key] + if active_only and value == 0: + continue + is_active = active_source == title and value != 0 - rect = rl.Rectangle(panel_x, y, SOURCE_ROW_W, SOURCE_ROW_H) + rect = rl.Rectangle(panel_x, y, rect_w, SOURCE_ROW_H) if is_active: bg = rl.Color(201, 34, 49, 166) @@ -317,53 +438,47 @@ def _draw_sources_panel(state: dict, panel_x: float, panel_y: float): rl.draw_rectangle_rounded(rect, 24 / SOURCE_ROW_H, 16, bg) rl.draw_rectangle_rounded_lines_ex(rect, 24 / SOURCE_ROW_H, 16, 10, border) - speed_text = f"{int(round(value))}" if value != 0 else "N/A" - full_text = f"{tr(title)} - {speed_text}" - - if is_active: - # Draw with black stroke outline - text_pos = rl.Vector2(rect.x + 20, rect.y + (SOURCE_ROW_H - 35) / 2) - rl.draw_text_ex(text_font, full_text, text_pos, 35, 0, rl.Color(255, 255, 255, 255)) + if abbreviated: + full_text = f"{abbrev}-{int(round(value))}" if value != 0 else f"{abbrev}-na" + else: + speed_text = f"{int(round(value))}{speed_unit}" if value != 0 else "N/A" + full_text = f"{tr(title)} - {speed_text}" + + text_pos = rl.Vector2(rect.x + 20, rect.y + (SOURCE_ROW_H - 35) / 2) + if is_active: + # Active row: bold text with black outline stroke + _draw_text_outlined(text_font, full_text, text_pos, 35, rl.Color(255, 255, 255, 255), rl.Color(0, 0, 0, 255)) else: - text_pos = rl.Vector2(rect.x + 20, rect.y + (SOURCE_ROW_H - 35) / 2) rl.draw_text_ex(text_font, full_text, text_pos, 35, 0, rl.Color(255, 255, 255, 255)) y += SOURCE_ROW_H + SOURCE_ROW_GAP -# ── Sign Rect Calculations ─────────────────────────────────────────── +# ── Sign Rect Calculation ───────────────────────────────────────────── def _calc_sign_rect(sign_x: float, sign_y: float, sign_width: float, use_vienna: bool) -> rl.Rectangle: + """Compute the rect the sign (pending or active) is drawn in.""" if use_vienna: return rl.Rectangle(sign_x, sign_y, EU_SIGN_SIZE, EU_SIGN_SIZE) return rl.Rectangle(sign_x, sign_y, sign_width, US_SIGN_HEIGHT) -def _calc_pending_rect(sign_x: float, sign_y: float, sign_width: float, - unconfirmed_speed: float, use_vienna: bool) -> rl.Rectangle: - # Pending sign is adjacent to main sign (Qt: translated by sign.width + UI_BORDER_SIZE) - pending_x = sign_x + sign_width + UI_BORDER_SIZE - - if use_vienna: - return rl.Rectangle(pending_x, sign_y, EU_SIGN_SIZE, EU_SIGN_SIZE) - - speed_text = str(int(round(unconfirmed_speed))) - w = PENDING_US_WIDE if len(speed_text) >= 3 else sign_width - return rl.Rectangle(pending_x, sign_y, w, US_SIGN_HEIGHT) - - # ── Click Handling ──────────────────────────────────────────────────── def handle_slc_click(mouse_pos, sign_x: float, sign_y: float, sign_width: float): - """Handle mouse click for accepting pending speed limit. Call from _handle_mouse_press.""" + """Handle a click on a pending (unconfirmed) speed limit sign to accept it. + + Only fires when `speedLimitChanged && unconfirmedSpeedLimitValid`; the click + target is the same rect the pending sign is drawn in. + """ state = _get_slc_state() - if state is None or not state['speed_limit_changed']: + if state is None or not (state['speed_limit_changed'] and state['unconfirmed_valid']): return - pending_rect = _calc_pending_rect(sign_x, sign_y, sign_width, - state['unconfirmed_speed_limit'], state['use_vienna']) - if (pending_rect.x <= mouse_pos.x <= pending_rect.x + pending_rect.width and - pending_rect.y <= mouse_pos.y <= pending_rect.y + pending_rect.height): + use_vienna = state['use_vienna'] + sign_rect = _calc_sign_rect(sign_x, sign_y, sign_width, use_vienna) + if (sign_rect.x <= mouse_pos.x <= sign_rect.x + sign_rect.width and + sign_rect.y <= mouse_pos.y <= sign_rect.y + sign_rect.height): from openpilot.common.params import Params Params(memory=True).put_bool("SpeedLimitAccepted", True) @@ -371,14 +486,21 @@ def handle_slc_click(mouse_pos, sign_x: float, sign_y: float, sign_width: float) # ── Public API ──────────────────────────────────────────────────────── def render_speed_limit(content_rect: rl.Rectangle): - """Render SLC speed limit signs. Call from onroad view render loop.""" + """Render SLC speed limit signs. Call from the onroad view render loop. + + Flow: + 1. If a pending (unconfirmed) limit is active, draw the PENDING sign in the + main sign rect and skip the regular sign. + 2. Otherwise, draw the regular sign (unless hidden by `HideSpeedLimit`). + 3. The sources panel appears only when `SpeedLimitSources` is on AND there is + no pending flash. + """ state = _get_slc_state() if state is None: return - # Compute sign rect matching Qt: - # signRect = QRect(setSpeedRect.x() + signMargin, setSpeedRect.bottom() + signMargin, - # setSpeedRect.width() - 2*signMargin, usSignHeight) + # Anchor the sign rect to the set-speed box (X offset 60, Y offset 45) and + # stack it below that box with a SIGN_MARGIN inset on each side. ss_width = SET_SPEED_WIDTH_MET if ui_state.is_metric else SET_SPEED_WIDTH_IMP ss_x = content_rect.x + SET_SPEED_X_OFFSET + (SET_SPEED_WIDTH_IMP - ss_width) // 2 ss_y = content_rect.y + SET_SPEED_Y_OFFSET @@ -388,42 +510,23 @@ def render_speed_limit(content_rect: rl.Rectangle): sign_y = ss_y + SET_SPEED_HEIGHT + SIGN_MARGIN use_vienna = state['use_vienna'] + sign_rect = _calc_sign_rect(sign_x, sign_y, sign_width, use_vienna) - # 1. Pending sign (always drawn when speedLimitChanged, even if main sign hidden) - if state['speed_limit_changed']: - pending_rect = _calc_pending_rect(sign_x, sign_y, sign_width, - state['unconfirmed_speed_limit'], use_vienna) - _draw_pending_sign(state, pending_rect.x, pending_rect.y, sign_width) + flashing_pending = state['speed_limit_changed'] and state['unconfirmed_valid'] - # 2. Main speed limit sign (unless hidden) - if not state['hide']: - _draw_speed_limit_sign(state, sign_x, sign_y, sign_width) + if flashing_pending: + _draw_sign(state, sign_rect, pending=True) + elif not state['hide']: + _draw_sign(state, sign_rect, pending=False) - # 2.5 Active source label below sign + # Single active-source label below sign when sources panel is OFF if not state['show_sources']: - source = state.get('speed_limit_source') - if source and source != "None" and source != "": - source_map = { - "Dashboard": "DASH", - "Map Data": "MAPS", - "Vision": "VISION", - "Mapbox": "MAPB", - "Upcoming": "NAV" - } - label = source_map.get(source, source.upper()) - font = _get_semi_bold() - font_size = 20 - sz = measure_text_cached(font, label, font_size) - cx = sign_x + (EU_SIGN_SIZE if use_vienna else sign_width) / 2 - bottom_y = sign_y + (EU_SIGN_SIZE if use_vienna else US_SIGN_HEIGHT) - rect = rl.Rectangle(cx - sz.x / 2 - 8, bottom_y + 8, sz.x + 16, font_size + 8) - rl.draw_rectangle_rounded(rect, 0.4, 8, rl.Color(0, 0, 0, 180)) - rl.draw_rectangle_rounded_lines_ex(rect, 0.4, 8, 1, rl.Color(255, 255, 255, 100)) - rl.draw_text_ex(font, label, rl.Vector2(cx - sz.x / 2, bottom_y + 12), font_size, 0, rl.WHITE) + cx_center = sign_x + (EU_SIGN_SIZE if use_vienna else sign_width) / 2 + bottom_y = sign_y + (EU_SIGN_SIZE if use_vienna else US_SIGN_HEIGHT) + _draw_active_source_label(state, cx_center, bottom_y) - # 3. Sources panel - if state['show_sources']: - sign_rect = _calc_sign_rect(sign_x, sign_y, sign_width, use_vienna) + # Sources panel (only when SpeedLimitSources is on AND no pending flash) + if state['show_sources'] and not flashing_pending: sources_x = sign_rect.x - SIGN_MARGIN sources_y = sign_rect.y + sign_rect.height + UI_BORDER_SIZE - _draw_sources_panel(state, sources_x, sources_y) + _draw_sources_panel(state, sources_x, sources_y, sign_width)