mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-05 05:44:03 +08:00
Drawer
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user