BigUI WIP: + SLC

This commit is contained in:
firestarsdog
2026-07-04 07:54:48 -04:00
parent b72570df40
commit 7e76fa9b41
3 changed files with 377 additions and 195 deletions
@@ -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"),
@@ -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)}%",
+282 -179
View File
@@ -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)