From cede5ddc9d2ef3f52264353cfcc00f6a44d3898a Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:09:42 -0400 Subject: [PATCH] Drawer --- .../ui/onroad/starpilot/slc_speed_limit.py | 127 +++++------------ .../onroad/starpilot/speed_source_drawer.py | 133 ++++++++++++++++++ .../onroad/starpilot/starpilot_onroad_view.py | 6 +- selfdrive/ui/onroad/starpilot/widget_style.py | 1 + selfdrive/ui/onroad/starpilot/widgets/base.py | 3 + .../onroad/starpilot/widgets/unified_speed.py | 75 ++++++++-- .../ui/tests/test_speed_source_drawer.py | 89 ++++++++++++ .../ui/tests/test_unified_speed_widget.py | 50 ++++++- 8 files changed, 373 insertions(+), 111 deletions(-) create mode 100644 selfdrive/ui/onroad/starpilot/speed_source_drawer.py create mode 100644 selfdrive/ui/tests/test_speed_source_drawer.py diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py index fc035741df..0c3624d647 100644 --- a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -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) diff --git a/selfdrive/ui/onroad/starpilot/speed_source_drawer.py b/selfdrive/ui/onroad/starpilot/speed_source_drawer.py new file mode 100644 index 0000000000..6d123f8bb7 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/speed_source_drawer.py @@ -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) diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 6af0edff30..8822a574ee 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -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) diff --git a/selfdrive/ui/onroad/starpilot/widget_style.py b/selfdrive/ui/onroad/starpilot/widget_style.py index 9588b1aa53..df31b39483 100644 --- a/selfdrive/ui/onroad/starpilot/widget_style.py +++ b/selfdrive/ui/onroad/starpilot/widget_style.py @@ -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 diff --git a/selfdrive/ui/onroad/starpilot/widgets/base.py b/selfdrive/ui/onroad/starpilot/widgets/base.py index 907cc818bb..e0d27c8783 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/base.py +++ b/selfdrive/ui/onroad/starpilot/widgets/base.py @@ -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 diff --git a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py index 756a510090..a99538cb3d 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py +++ b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py @@ -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) diff --git a/selfdrive/ui/tests/test_speed_source_drawer.py b/selfdrive/ui/tests/test_speed_source_drawer.py new file mode 100644 index 0000000000..91c34c1847 --- /dev/null +++ b/selfdrive/ui/tests/test_speed_source_drawer.py @@ -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] diff --git a/selfdrive/ui/tests/test_unified_speed_widget.py b/selfdrive/ui/tests/test_unified_speed_widget.py index af5c30432c..1a24803bff 100644 --- a/selfdrive/ui/tests/test_unified_speed_widget.py +++ b/selfdrive/ui/tests/test_unified_speed_widget.py @@ -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):