This commit is contained in:
firestarsdog
2026-10-03 18:09:42 -04:00
parent 385428f6ea
commit cede5ddc9d
8 changed files with 373 additions and 111 deletions
@@ -6,12 +6,10 @@ from openpilot.selfdrive.ui.ui_state import ui_state
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
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import (
CONTROL_BORDER, CONTROL_ROUNDNESS, CONTROL_SEGMENTS,
)
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import UNIFIED_ACCENT
from openpilot.selfdrive.ui.onroad.starpilot.source_bubble_layout import (
enabled_source_titles, fit_source_label, source_abbreviated_value_text,
source_content_metrics, source_value_text, visible_source_rows,
source_value_text, visible_source_rows,
)
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.selfdrive.ui.lib.speed_limit_pulse import SpeedLimitPulse
@@ -165,24 +163,16 @@ def _get_semi_bold():
return _font_semi_bold
# ── Sources Bubble (expandable overlay) ────────────────────────────────
# ── Source contents ───────────────────────────────────────────────────
# Fixed outer footprint; the content scale adapts to the visible row count.
_SOURCE_PANEL_WIDTH = 248
_SOURCE_PANEL_GAP = 20
_SOURCE_PANEL_PAD_X = 9
_SOURCE_PANEL_PAD_Y = 2
_SOURCE_PANEL_PAD_X = 16
_SOURCE_PANEL_PAD_Y = 12
_SOURCE_PANEL_BG = rl.Color(0, 0, 0, 175)
_SOURCE_PANEL_BORDER = rl.Color(196, 205, 208, 80)
_SOURCE_DIVIDER = rl.Color(196, 205, 208, 100)
_SOURCE_ACTIVE_BAR = rl.Color(CONTROL_BORDER.r, CONTROL_BORDER.g, CONTROL_BORDER.b, 230)
_SOURCE_ICON_MUTED = rl.Color(160, 170, 175, 200)
_SOURCE_LABEL_MUTED = rl.Color(166, 166, 166, 255)
_SOURCE_ACTIVE_BAR_WIDTH = 6.0
_SOURCE_ACTIVE_BAR_HEIGHT = 36.0
_SOURCE_ACTIVE_BAR_X = 2.0
_SOURCE_ACTIVE_BAR_ROW_INSET = 3.0
_SOURCE_MIN_LABEL_VALUE_GAP = 6.0
_SOURCE_DIVIDER = rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 90)
_SOURCE_LABEL_MUTED = rl.Color(170, 179, 174, 255)
_SOURCE_LABEL_SIZE = 26
_SOURCE_VALUE_SIZE = 28
_SOURCE_MIN_LABEL_VALUE_GAP = 12
_SOURCE_COMPACT_LABELS = {
"Dashboard": "Dash",
@@ -315,8 +305,8 @@ def _draw_sources_bubble_empty_state(panel_rect: rl.Rectangle) -> None:
curr_y += round(sz.y + line_gap)
def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
"""Draw the expanded source list attached to the SLC card."""
def _draw_source_contents(state: dict, panel_rect: rl.Rectangle) -> None:
"""Draw raw source readings; the accepted source is white and bold."""
font_semi = _get_semi_bold()
font_bold = _get_bold()
active_source = state['speed_limit_source']
@@ -324,29 +314,7 @@ def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
active_only = state.get('slc_active_sources_only', False)
abbreviated = state.get('slc_abbreviated_sources', False)
panel_rect = rl.Rectangle(
sign_rect.x + sign_rect.width + _SOURCE_PANEL_GAP,
sign_rect.y,
_SOURCE_PANEL_WIDTH,
sign_rect.height,
)
rl.draw_rectangle_rounded(panel_rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, _SOURCE_PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(
panel_rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 1, _SOURCE_PANEL_BORDER,
)
rows = [
(
panel_label,
_SOURCE_COMPACT_LABELS[panel_label],
icon_key,
value,
is_active,
)
for panel_label, icon_key, value, is_active in visible_source_rows(
SOURCE_DEFS, state, active_source, enabled_sources, active_only,
)
]
rows = visible_source_rows(SOURCE_DEFS, state, active_source, enabled_sources, active_only)
if not rows:
_draw_sources_bubble_empty_state(panel_rect)
@@ -355,81 +323,54 @@ def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
row_h = (panel_rect.height - 2 * _SOURCE_PANEL_PAD_Y) / len(rows)
content_left = panel_rect.x + _SOURCE_PANEL_PAD_X
content_right = panel_rect.x + panel_rect.width - _SOURCE_PANEL_PAD_X
font_size, icon_size, icon_gap = source_content_metrics(len(rows))
label_left = (
content_left + _SOURCE_ACTIVE_BAR_WIDTH + _SOURCE_MIN_LABEL_VALUE_GAP
if abbreviated else content_left + icon_size + icon_gap
)
for index, (panel_label, compact_label, icon_key, value, is_active) in enumerate(rows):
for index, (panel_label, _icon_key, value, is_active) in enumerate(rows):
font = font_bold if is_active else font_semi
compact_label = tr(_SOURCE_COMPACT_LABELS[panel_label])
row_y = panel_rect.y + _SOURCE_PANEL_PAD_Y + index * row_h
if index:
divider_y = round(row_y)
if panel_label == "Next" and index:
rl.draw_line_ex(
rl.Vector2(content_left, divider_y),
rl.Vector2(content_right, divider_y),
rl.Vector2(content_left, row_y),
rl.Vector2(content_right, row_y),
1,
_SOURCE_DIVIDER,
)
if is_active:
active_bar_height = min(
_SOURCE_ACTIVE_BAR_HEIGHT,
max(10.0, row_h - 2 * _SOURCE_ACTIVE_BAR_ROW_INSET),
)
active_bar_rect = rl.Rectangle(
panel_rect.x + _SOURCE_ACTIVE_BAR_X,
round(row_y + (row_h - active_bar_height) / 2),
_SOURCE_ACTIVE_BAR_WIDTH,
active_bar_height,
)
rl.draw_rectangle_rounded(active_bar_rect, 0.5, 4, _SOURCE_ACTIVE_BAR)
value_text = source_value_text(value)
text_color = _WHITE if is_active else _SOURCE_LABEL_MUTED
if abbreviated:
text_font = font_bold if is_active else font_semi
label_text = fit_source_label(
f"{tr(compact_label)}-{source_abbreviated_value_text(value)}",
f"{compact_label}-{source_abbreviated_value_text(value)}",
"",
content_right - label_left,
lambda text, font=text_font: measure_text_cached(font, text, font_size).x,
content_right - content_left,
lambda text, font=font: measure_text_cached(font, text, _SOURCE_LABEL_SIZE).x,
)
label_size = measure_text_cached(text_font, label_text, font_size)
label_size = measure_text_cached(font, label_text, _SOURCE_LABEL_SIZE)
text_y = round(row_y + (row_h - label_size.y) / 2)
rl.draw_text_ex(
text_font,
font,
label_text,
rl.Vector2(label_left, text_y),
font_size,
rl.Vector2(content_left, text_y),
_SOURCE_LABEL_SIZE,
0,
text_color,
)
continue
compact_label = tr(compact_label)
full_label = tr(panel_label)
value_size = measure_text_cached(font_bold, value_text, font_size)
value_size = measure_text_cached(font, value_text, _SOURCE_VALUE_SIZE)
max_label_width = max(
0.0,
content_right - label_left - _SOURCE_MIN_LABEL_VALUE_GAP - value_size.x,
content_right - content_left - _SOURCE_MIN_LABEL_VALUE_GAP - value_size.x,
)
label_text = fit_source_label(
full_label,
compact_label,
max_label_width,
lambda text: measure_text_cached(font_semi, text, font_size).x,
lambda text, font=font: measure_text_cached(font, text, _SOURCE_LABEL_SIZE).x,
)
label_size = measure_text_cached(font_semi, label_text, font_size)
text_height = max(label_size.y, value_size.y)
text_y = round(row_y + (row_h - text_height) / 2)
icon_y = round(row_y + (row_h - icon_size) / 2)
icon_color = _WHITE if is_active else _SOURCE_ICON_MUTED
_draw_source_icon(icon_key, content_left, icon_y, icon_size, icon_color)
label_pos = rl.Vector2(label_left, text_y)
value_pos = rl.Vector2(round(content_right - value_size.x), text_y)
rl.draw_text_ex(font_semi, label_text, label_pos, font_size, 0, text_color)
rl.draw_text_ex(font_bold, value_text, value_pos, font_size, 0, text_color)
label_size = measure_text_cached(font, label_text, _SOURCE_LABEL_SIZE)
label_pos = rl.Vector2(content_left, round(row_y + (row_h - label_size.y) / 2))
value_pos = rl.Vector2(round(content_right - value_size.x), round(row_y + (row_h - value_size.y) / 2))
rl.draw_text_ex(font, label_text, label_pos, _SOURCE_LABEL_SIZE, 0, text_color)
rl.draw_text_ex(font, value_text, value_pos, _SOURCE_VALUE_SIZE, 0, text_color)
@@ -0,0 +1,133 @@
"""The source drawer's reveal, shared frame, and clipped contents."""
import math
import pyray as rl
from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import _draw_source_contents
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import CONTROL_ROUNDNESS, CONTROL_SEGMENTS
SOURCE_DRAWER_WIDTH = 248
OPEN_SECONDS = 0.18
CLOSE_SECONDS = 0.14
_QUARTER = tuple((math.cos(i * math.pi / (2 * CONTROL_SEGMENTS)), math.sin(i * math.pi / (2 * CONTROL_SEGMENTS)))
for i in range(CONTROL_SEGMENTS + 1))
def _outline(rect: rl.Rectangle, drawer_top: float, extension: float) -> list[tuple[float, float]]:
left, top = rect.x, rect.y
card_right, bottom = left + rect.width, top + rect.height
right = card_right + extension
radius = min(rect.width, rect.height) * CONTROL_ROUNDNESS / 2
points = []
def corner(cx, cy, r, quadrant):
for x, y in _QUARTER:
dx, dy = ((-x, -y), (y, -x), (x, y), (-y, x))[quadrant]
point = (cx + dx * r, cy + dy * r)
if not points or math.hypot(point[0] - points[-1][0], point[1] - points[-1][1]) > 1e-5:
points.append(point)
corner(left + radius, top + radius, radius, 0)
if drawer_top <= top:
corner(right - radius, top + radius, radius, 1)
else:
corner(card_right - radius, top + radius, radius, 1)
points.append((card_right, drawer_top))
tip_radius = min(radius, extension)
corner(right - tip_radius, drawer_top + tip_radius, tip_radius, 1)
corner(right - radius, bottom - radius, radius, 2)
corner(left + radius, bottom - radius, radius, 3)
return points
class SpeedSourceDrawer:
def __init__(self):
self._mesh_key = None
self._fill = None
self._strokes = {}
self.reset()
def reset(self) -> None:
self.progress = 0.0
self._open = False
self._start_progress = 0.0
self._start_time = 0.0
@property
def width(self) -> float:
return SOURCE_DRAWER_WIDTH * self.progress
def update(self, opened: bool, now: float) -> None:
# Sample the old transition first so a second tap reverses without jumping.
target = float(self._open)
if self.progress != target:
duration = OPEN_SECONDS if self._open else CLOSE_SECONDS
phase = min(1.0, max(0.0, (now - self._start_time) / duration))
self.progress = target + (self._start_progress - target) * (1 - phase) ** 4
if opened != self._open:
self._open = opened
self._start_progress = self.progress
self._start_time = now
def bounds(self, rect: rl.Rectangle, drawer_top: float) -> rl.Rectangle:
return rl.Rectangle(rect.x + rect.width, drawer_top, self.width, rect.y + rect.height - drawer_top)
def _prepare_mesh(self, rect: rl.Rectangle, drawer_top: float) -> None:
key = (rect.x, rect.y, rect.width, rect.height, drawer_top, self.width)
if key == self._mesh_key:
return
points = _outline(rect, drawer_top, self.width)
# The lower-left interior sees the entire L-shaped outline without overlap.
center = (rect.x + rect.width / 2, (drawer_top + rect.y + rect.height) / 2)
self._fill = rl.ffi.new('Vector2[]', [center, *reversed(points), points[-1]])
self._points = points
self._miters = []
for i, (x, y) in enumerate(points):
px, py = points[i - 1]
nx, ny = points[(i + 1) % len(points)]
before, after = math.hypot(x - px, y - py), math.hypot(nx - x, ny - y)
ax, ay = (y - py) / before, -(x - px) / before
bx, by = (ny - y) / after, -(nx - x) / after
denominator = 1 + ax * bx + ay * by
self._miters.append(((ax + bx) / denominator, (ay + by) / denominator))
self._strokes.clear()
self._mesh_key = key
def draw_border(self, rect: rl.Rectangle, drawer_top: float, width: float, color: rl.Color) -> None:
self._prepare_mesh(rect, drawer_top)
if width not in self._strokes:
vertices = []
for (x, y), (mx, my) in zip(self._points + [self._points[0]], self._miters + [self._miters[0]], strict=True):
vertices.extend(((x + mx * width, y + my * width), (x, y)))
self._strokes[width] = rl.ffi.new('Vector2[]', vertices)
vertices = self._strokes[width]
rl.draw_triangle_strip(rl.ffi.cast('Vector2 *', vertices), len(vertices), color)
def draw_frame(self, rect: rl.Rectangle, drawer_top: float, fill: rl.Color, border: rl.Color) -> None:
self._prepare_mesh(rect, drawer_top)
rl.draw_triangle_fan(rl.ffi.cast('Vector2 *', self._fill), len(self._fill), fill)
self.draw_border(rect, drawer_top, 7, rl.Color(border.r, border.g, border.b, 55))
self.draw_border(rect, drawer_top, 2, border)
def draw_contents(self, state: dict, rect: rl.Rectangle, drawer_top: float) -> None:
bounds = self.bounds(rect, drawer_top)
panel = rl.Rectangle(bounds.x + bounds.width - SOURCE_DRAWER_WIDTH, bounds.y, SOURCE_DRAWER_WIDTH, bounds.height)
# Scissor is not stacked in Raylib. This is a top-level HUD widget.
rl.rl_draw_render_batch_active()
rl.begin_scissor_mode(math.ceil(bounds.x), math.ceil(bounds.y), math.ceil(bounds.width), math.ceil(bounds.height))
try:
_draw_source_contents(state, panel)
radius = min(rect.width, rect.height) * CONTROL_ROUNDNESS / 2
top, bottom = drawer_top + radius, rect.y + rect.height - radius
cap, width = 16, min(12, bounds.width)
shade, clear = rl.Color(0, 0, 0, 108), rl.BLANK
rl.draw_rectangle_gradient_ex(rl.Rectangle(bounds.x, top, width, cap), clear, shade, clear, clear)
rl.draw_rectangle_gradient_h(math.ceil(bounds.x), math.ceil(top + cap), math.ceil(width), int(bottom - top - 2 * cap), shade, clear)
rl.draw_rectangle_gradient_ex(rl.Rectangle(bounds.x, bottom - cap, width, cap), shade, clear, clear, clear)
finally:
rl.rl_draw_render_batch_active()
rl.end_scissor_mode()
rim = rl.Color(230, 218, 246, round(46 * self.progress))
rl.draw_line_ex(rl.Vector2(bounds.x - 0.5, drawer_top + 16),
rl.Vector2(bounds.x - 0.5, rect.y + rect.height - 16), 1, rim)
@@ -123,6 +123,7 @@ class StarPilotOnroadView(AugmentedRoadView):
super()._render(rect)
if not ui_state.started:
self._unified_speed_widget.collapse_sources()
self._favorite_radial_menu.collapse()
return
@@ -132,6 +133,8 @@ class StarPilotOnroadView(AugmentedRoadView):
self._render_speed_card()
self._render_overlays()
self._render_road_name()
else:
self._unified_speed_widget.collapse_sources()
self._pip_sidecam.render(self._content_rect)
@@ -165,6 +168,7 @@ class StarPilotOnroadView(AugmentedRoadView):
def _render_speed_card(self):
if self._full_alert_showing():
self._unified_speed_widget.collapse_sources()
return
if self._unified_speed_widget.is_visible:
self._unified_speed_widget.render(self._unified_speed_widget.rect)
@@ -225,7 +229,7 @@ class StarPilotOnroadView(AugmentedRoadView):
# Check if click maps to any of the layout widgets
for zone in self.layout_manager.zones.values():
for widget in zone:
if widget.is_visible and widget.blocks_pointer and rl.check_collision_point_rec(mouse_pos, widget.rect):
if widget.is_visible and widget.blocks_pointer and widget.contains_pointer(mouse_pos):
return
super()._handle_mouse_press(mouse_pos)
@@ -15,6 +15,7 @@ CONTROL_SEGMENTS = 10
CONTROL_BORDER_WIDTH = 6
CONTROL_BG = rl.Color(0, 0, 0, 166)
CONTROL_BORDER = rl.Color(196, 205, 208, 180)
UNIFIED_ACCENT = rl.Color(160, 96, 230, 230)
# The layout manager has historically anchored the left controls at x + 146.
# Keep that placement stable while making the width explicit and shared.
WIDGET_ANCHOR_OFFSET = 146
@@ -22,6 +22,9 @@ class LayoutWidget(Widget):
"""Whether this visual should suppress the on-road background tap."""
return True
def contains_pointer(self, mouse_pos) -> bool:
return rl.check_collision_point_rec(mouse_pos, self.rect)
def _render(self, rect: rl.Rectangle) -> bool | int | None:
# Subclasses will implement self._render instead of render
# to integrate with openpilot.system.ui.widgets.Widget
@@ -9,13 +9,14 @@ from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.onroad.hud_renderer import COLORS
from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import (
_draw_source_icon, _draw_sources_bubble, _get_slc_state, _is_slc_enabled, _speed_limit_pulse_color, source_icon_key,
_draw_source_icon, _get_slc_state, _is_slc_enabled, _speed_limit_pulse_color, source_icon_key,
)
from openpilot.selfdrive.ui.onroad.starpilot.speed_source_drawer import SpeedSourceDrawer
from openpilot.selfdrive.ui.onroad.starpilot.unified_speed_presentation import (
UnifiedSpeedPresentation, resolve_unified_speed,
)
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import (
CONTROL_BG, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, draw_control_card,
CONTROL_BG, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, UNIFIED_ACCENT, draw_control_card,
)
from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
@@ -41,7 +42,6 @@ PAUSE_ICON_GAP = 8
OFFSET_FONT_SIZE = 22
INLINE_OFFSET_FONT_SIZE = 28
CONFIRMATION_COLOR = rl.Color(188, 132, 255, 255)
UNIFIED_ACCENT = rl.Color(160, 96, 230, 230)
PAUSE_COLOR = rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 255)
@@ -100,6 +100,16 @@ class UnifiedSpeedWidget(LayoutWidget):
self._show_max = False
self._pedal_override = False
self._snapshot_frame: int | None = None
self._source_drawer = SpeedSourceDrawer()
self.set_touch_event_valid_callback(lambda event: self.contains_pointer(event.pos))
def collapse_sources(self) -> None:
"""Hide the visual drawer without changing the user's persistent preference."""
self._source_drawer.reset()
def hide_event(self) -> None:
self.collapse_sources()
super().hide_event()
def _refresh_snapshot(self) -> None:
frame = getattr(ui_state.sm, "frame", None)
@@ -125,7 +135,10 @@ class UnifiedSpeedWidget(LayoutWidget):
@property
def is_visible(self) -> bool:
self._refresh_snapshot()
return self._show_max or self._presentation.mode != "max_only"
visible = self._show_max or self._presentation.mode != "max_only"
if not visible:
self.collapse_sources()
return visible
def get_size(self) -> tuple[float, float]:
self._refresh_snapshot()
@@ -133,13 +146,26 @@ class UnifiedSpeedWidget(LayoutWidget):
return float(UNIFIED_WIDTH), float(height)
@property
def _hit_rect(self) -> rl.Rectangle:
def _card_hit_rect(self) -> rl.Rectangle:
rect = self.rect
return rl.Rectangle(
rect.x, rect.y - self.TOUCH_SLOP,
rect.width + self.TOUCH_SLOP, rect.height + 2 * self.TOUCH_SLOP,
)
@property
def _hit_rect(self) -> rl.Rectangle:
rect = self._card_hit_rect
return rl.Rectangle(rect.x, rect.y, rect.width + self._source_drawer.width, rect.height)
def contains_pointer(self, mouse_pos) -> bool:
return (rl.check_collision_point_rec(mouse_pos, self._card_hit_rect) or
(self._source_drawer.width > 0 and rl.check_collision_point_rec(mouse_pos, self._source_bounds())))
def _source_bounds(self) -> rl.Rectangle:
limit = self._speed_limit_bounds(self.rect) or self.rect
return self._source_drawer.bounds(self.rect, limit.y)
def _speed_limit_bounds(self, rect: rl.Rectangle) -> rl.Rectangle | None:
mode = self._presentation.mode
if mode in ("split", "merged"):
@@ -283,10 +309,19 @@ class UnifiedSpeedWidget(LayoutWidget):
def _draw_speed_limit_border(self, rect: rl.Rectangle, limit: rl.Rectangle, color: rl.Color) -> None:
# Clip the shared rounded outline so only the lower Speed Limit row changes.
rl.begin_scissor_mode(int(limit.x), int(limit.y), int(limit.width + 1), int(limit.height + 1))
drawer = self._source_drawer
if drawer.width > 0:
rl.rl_draw_render_batch_active()
rl.begin_scissor_mode(int(limit.x - 3), int(limit.y), math.ceil(limit.width + drawer.width + 6), int(limit.height + 4))
else:
rl.begin_scissor_mode(int(limit.x), int(limit.y), int(limit.width + 1), int(limit.height + 1))
try:
rl.draw_rectangle_rounded_lines_ex(rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 3, color)
if drawer.width > 0:
drawer.draw_border(rect, limit.y, 3, color)
else:
rl.draw_rectangle_rounded_lines_ex(rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 3, color)
finally:
rl.rl_draw_render_batch_active()
rl.end_scissor_mode()
if self._presentation.mode == "split":
rl.draw_line_ex(rl.Vector2(rect.x + 8, limit.y), rl.Vector2(rect.x + rect.width - 8, limit.y), 3, color)
@@ -295,12 +330,20 @@ class UnifiedSpeedWidget(LayoutWidget):
presentation = self._presentation
state = self._slc_state
speed_color = COLORS.DISENGAGED if self._pedal_override else COLORS.WHITE
rl.draw_rectangle_rounded_lines_ex(
rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 7,
rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 55),
)
draw_control_card(rect, fill=CONTROL_BG, border=UNIFIED_ACCENT, border_width=2)
limit_bounds = self._speed_limit_bounds(rect)
drawer = self._source_drawer
if state is None or presentation.confirmation_pending:
drawer.reset()
else:
drawer.update(ui_state.ui_params.get_bool("SpeedLimitSources"), rl.get_time())
if drawer.width > 0:
drawer.draw_frame(rect, (limit_bounds or rect).y, CONTROL_BG, UNIFIED_ACCENT)
else:
rl.draw_rectangle_rounded_lines_ex(
rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 7,
rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 55),
)
draw_control_card(rect, fill=CONTROL_BG, border=UNIFIED_ACCENT, border_width=2)
if presentation.mode == "split":
rl.draw_line_ex(
rl.Vector2(rect.x + 8, limit_bounds.y), rl.Vector2(rect.x + rect.width - 8, limit_bounds.y),
@@ -361,8 +404,8 @@ class UnifiedSpeedWidget(LayoutWidget):
vision_color = _speed_limit_pulse_color(UNIFIED_ACCENT, UNIFIED_ACCENT.a)
if (vision_color.r, vision_color.g, vision_color.b) != (UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b):
self._draw_speed_limit_border(rect, limit_bounds, vision_color)
if state is not None and ui_state.ui_params.get_bool("SpeedLimitSources"):
_draw_sources_bubble(state, limit_bounds or rect)
if drawer.width > 0:
drawer.draw_contents(state, rect, (limit_bounds or rect).y)
def _handle_mouse_press(self, mouse_pos) -> None:
limit = self._speed_limit_bounds(self.rect)
@@ -375,7 +418,9 @@ class UnifiedSpeedWidget(LayoutWidget):
top_slop = 0 if self._presentation.mode in ("split", "merged") else self.TOUCH_SLOP
target = rl.Rectangle(limit.x, limit.y - top_slop,
limit.width + self.TOUCH_SLOP, limit.height + top_slop + self.TOUCH_SLOP)
if not rl.check_collision_point_rec(mouse_pos, target):
in_drawer = (not self._presentation.confirmation_pending and self._source_drawer.width > 0 and
rl.check_collision_point_rec(mouse_pos, self._source_bounds()))
if not rl.check_collision_point_rec(mouse_pos, target) and not in_drawer:
return
if self._presentation.confirmation_pending:
Params(memory=True).put_bool("SpeedLimitAccepted", True)
@@ -0,0 +1,89 @@
import math
import pyray as rl
import pytest
from openpilot.selfdrive.ui.onroad.starpilot import speed_source_drawer as drawer_module
from openpilot.selfdrive.ui.onroad.starpilot.speed_source_drawer import SpeedSourceDrawer, _outline
@pytest.fixture(autouse=True)
def no_gpu_batch(monkeypatch):
monkeypatch.setattr(rl, "rl_draw_render_batch_active", lambda: None)
def test_motion_is_bounded_finishes_on_time_and_reverses_without_jumping():
drawer = SpeedSourceDrawer()
drawer.update(True, 1)
assert drawer.progress == 0
drawer.update(True, 1.09)
assert 0 < drawer.progress < 1
before_reverse = drawer.progress
drawer.update(False, 1.09)
assert drawer.progress == before_reverse
drawer.update(False, 1.13)
assert 0 < drawer.progress < before_reverse
drawer.update(False, 1.24)
assert drawer.progress == 0
drawer.update(True, 2)
drawer.update(True, 2.18)
assert drawer.progress == 1
drawer.update(False, 3)
drawer.update(False, 3.14)
assert drawer.progress == 0
@pytest.mark.parametrize("height,top", [(448, 283), (250, 75)])
@pytest.mark.parametrize("extension", [0.0001, 1, 12, 40.6, 124, 248])
def test_shared_surface_has_no_overlapping_fill_and_preserves_bottom_edge(height, top, extension):
rect = rl.Rectangle(30, 75, 232, height)
points = _outline(rect, top, extension)
center = (146, (top + 75 + height) / 2)
triangles = [((x - center[0]) * (ny - center[1]) - (y - center[1]) * (nx - center[0])) / 2
for (x, y), (nx, ny) in zip(points, points[1:] + points[:1], strict=True)]
# Every fan triangle has the same winding: the translucent surface is painted once.
assert all(area >= -1e-8 for area in triangles)
assert all(math.isfinite(value) for point in points for value in point)
assert min(y for _x, y in points) == 75
assert max(y for _x, y in points) == 75 + height
assert max(x for x, _y in points) == pytest.approx(262 + extension)
bottom = [x for x, y in points if y == 75 + height]
assert min(bottom) == pytest.approx(70.6)
assert max(bottom) == pytest.approx(262 + extension - 40.6)
def test_settled_frame_reuses_mesh_and_border_buffers(monkeypatch):
for name in ("draw_triangle_fan", "draw_triangle_strip"):
monkeypatch.setattr(rl, name, lambda *args: None)
drawer = SpeedSourceDrawer()
drawer.update(True, 0)
drawer.update(True, 1)
rect = rl.Rectangle(30, 75, 232, 448)
border = rl.Color(255, 255, 255, 255)
drawer.draw_frame(rect, 283, rl.BLACK, border)
fill, strokes = drawer._fill, dict(drawer._strokes)
for _ in range(10):
drawer.draw_frame(rect, 283, rl.BLACK, border)
assert drawer._fill is fill
assert all(drawer._strokes[width] is buffer for width, buffer in strokes.items())
drawer.draw_border(rect, 283, 3, border)
assert drawer._fill is fill
assert len(drawer._strokes) == 3
def test_fixed_width_contents_translate_behind_card_and_clip_is_restored_on_failure(monkeypatch):
drawer = SpeedSourceDrawer()
drawer.progress = 0.5
clips, ended = [], []
monkeypatch.setattr(rl, "begin_scissor_mode", lambda *args: clips.append(args))
monkeypatch.setattr(rl, "end_scissor_mode", lambda: ended.append(True))
def fail_contents(_state, panel):
assert (panel.x, panel.y, panel.width, panel.height) == (138, 283, 248, 240)
raise ValueError("source rendering failed")
monkeypatch.setattr(drawer_module, "_draw_source_contents", fail_contents)
with pytest.raises(ValueError, match="source rendering failed"):
drawer.draw_contents({}, rl.Rectangle(30, 75, 232, 448), 283)
assert clips == [(262, 283, 124, 240)]
assert ended == [True]
@@ -11,6 +11,11 @@ from openpilot.selfdrive.ui.onroad.starpilot.unified_speed_presentation import U
from openpilot.selfdrive.ui.onroad.starpilot.widgets import unified_speed
@pytest.fixture(autouse=True)
def no_gpu_batch(monkeypatch):
monkeypatch.setattr(rl, "rl_draw_render_batch_active", lambda: None)
def make_widget(mode="split", pending=False):
widget = object.__new__(unified_speed.UnifiedSpeedWidget)
height = unified_speed.UNIFIED_HEIGHT if mode in ("split", "merged") else unified_speed.SINGLE_HEIGHT
@@ -23,6 +28,7 @@ def make_widget(mode="split", pending=False):
widget._semi_bold_digit_center = widget._bold_digit_center = 0.5
widget._bold_digit_bottom = 0.8
widget._unit_tops = {"mph": 0.0, "km/h": 0.0}
widget._source_drawer = unified_speed.SpeedSourceDrawer()
widget.hud_renderer = SimpleNamespace(is_cruise_set=True)
return widget
@@ -390,6 +396,9 @@ def test_sources_panel_is_attached_to_slc_row(monkeypatch, mode):
widget = make_widget(mode)
widget._show_max = mode != "limit_only"
widget._slc_state = {"slc_overridden_speed": 0}
widget._source_drawer.update(True, 0)
widget._source_drawer.update(True, 1)
monkeypatch.setattr(rl, "get_time", lambda: 1)
monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(
status=unified_speed.UIStatus.DISENGAGED, ui_params=SimpleNamespace(get_bool=lambda _key: True),
))
@@ -400,14 +409,51 @@ def test_sources_panel_is_attached_to_slc_row(monkeypatch, mode):
for name in ("_draw_header", "_draw_centered_text", "_draw_posted_limit", "_draw_unit", "_unit_y"):
monkeypatch.setattr(widget, name, lambda *args, **kwargs: None)
panels = []
monkeypatch.setattr(unified_speed, "_draw_sources_bubble", lambda _state, bounds: panels.append(bounds))
monkeypatch.setattr(widget._source_drawer, "draw_frame", lambda *args: None)
monkeypatch.setattr(widget._source_drawer, "draw_contents", lambda _state, rect, top: panels.append(widget._source_drawer.bounds(rect, top)))
widget._render(widget.rect)
assert len(panels) == 1
panel = panels[0]
assert (panel.x, panel.width) == (widget.rect.x, widget.rect.width)
assert (panel.x, panel.width) == (widget.rect.x + widget.rect.width, 248)
assert (panel.y, panel.height) == ((283, 240) if mode in ("split", "merged") else (75, 250))
def test_drawer_taps_toggle_sources_and_empty_upper_area_does_not(monkeypatch):
widget = make_widget("merged")
widget._source_drawer.update(True, 0)
widget._source_drawer.update(True, 1)
writes = []
params = SimpleNamespace(get_bool=lambda _key: True, put_bool=lambda key, value: writes.append((key, value)))
monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(ui_params=params))
assert widget.contains_pointer(rl.Vector2(400, 350))
assert not widget.contains_pointer(rl.Vector2(400, 200))
assert not widget.contains_pointer(rl.Vector2(511, 350))
widget._handle_mouse_press(rl.Vector2(400, 200))
assert writes == []
widget._handle_mouse_press(rl.Vector2(400, 350))
assert writes == [("SpeedLimitSources", False)]
widget.collapse_sources()
assert not widget.contains_pointer(rl.Vector2(400, 350))
@pytest.mark.parametrize("pending,stale", [(True, False), (False, True)])
def test_pending_confirmation_and_stale_state_hide_drawer_without_changing_preference(monkeypatch, pending, stale):
widget = make_widget(pending=pending)
widget._slc_state = None if stale else {"slc_overridden_speed": 0}
widget._source_drawer.update(True, 0)
widget._source_drawer.update(True, 1)
monkeypatch.setattr(unified_speed, "ui_state", SimpleNamespace(status=unified_speed.UIStatus.DISENGAGED,
ui_params=SimpleNamespace(get_bool=lambda _key: True)))
monkeypatch.setattr(unified_speed, "draw_control_card", lambda *args, **kwargs: None)
for name in ("draw_rectangle_rounded_lines_ex", "draw_line_ex", "draw_spline_segment_bezier_cubic", "begin_scissor_mode", "end_scissor_mode"):
monkeypatch.setattr(rl, name, lambda *args: None)
for name in ("_draw_header", "_draw_centered_text", "_draw_posted_limit", "_draw_unit", "_unit_y"):
monkeypatch.setattr(widget, name, lambda *args, **kwargs: None)
widget._render(widget.rect)
assert widget._source_drawer.width == 0
assert not widget.contains_pointer(rl.Vector2(400, 350))
@pytest.fixture
def slc_ui(monkeypatch):
class Params(dict):