diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 273cd6490..48102e402 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -61,6 +61,7 @@ class AugmentedRoadView(CameraView): self._driver_stream_active = False self._draw_road_overlays = True self._draw_hud_controls = True + self._draw_driver_state = True self.model_renderer = ModelRenderer() self._hud_renderer = HudRenderer() @@ -118,7 +119,8 @@ class AugmentedRoadView(CameraView): if self._draw_hud_controls: self._hud_renderer.render(self._content_rect) self.alert_renderer.render(self._content_rect) - self.driver_state_renderer.render(self._content_rect) + if self._draw_driver_state: + self.driver_state_renderer.render(self._content_rect) # Custom UI extension point - add custom overlays here # Use self._content_rect for positioning within camera bounds diff --git a/selfdrive/ui/onroad/driver_state.py b/selfdrive/ui/onroad/driver_state.py index 6a4338e23..8dff686a8 100644 --- a/selfdrive/ui/onroad/driver_state.py +++ b/selfdrive/ui/onroad/driver_state.py @@ -24,6 +24,7 @@ class DriverStateRenderer(Widget): self.position_x: float = 0.0 self.position_y: float = 0.0 self.x_shift: float = 0.0 + self.auto_position = True self.is_active = False self.is_rhd = False self.is_face_detected = False @@ -112,6 +113,8 @@ class DriverStateRenderer(Widget): self._pre_calculate_position() def _pre_calculate_position(self): + if not self.auto_position: + return width, height = self._rect.width, self._rect.height offset = UI_BORDER_SIZE + BTN_SIZE // 2 + 20 self.position_x = self._rect.x + (width - offset if self.is_rhd else offset) + self.x_shift diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 8604d76e0..2cc3b5281 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -75,6 +75,9 @@ class HudRenderer(Widget): self._exp_button: ExpButton = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size) self._navigation_card = NavigationCardRenderer() + self.draw_set_speed = True + self.draw_exp_button = True + def _update_state(self) -> None: """Update HUD state based on car state and controls state.""" sm = ui_state.sm @@ -122,7 +125,7 @@ class HudRenderer(Widget): COLORS.HEADER_GRADIENT_END, ) - if self.is_cruise_available and not ui_state.starpilot_toggles.get("hide_max_speed", False): + if self.draw_set_speed and self.is_cruise_available and not ui_state.starpilot_toggles.get("hide_max_speed", False): self._draw_set_speed(rect) if not ui_state.starpilot_toggles.get("hide_speed", False): @@ -132,7 +135,8 @@ class HudRenderer(Widget): button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size button_y = rect.y + UI_CONFIG.border_size - self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size)) + if self.draw_exp_button: + self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size)) def user_interacting(self) -> bool: return self._exp_button.is_pressed or self._navigation_card.is_pressed diff --git a/selfdrive/ui/onroad/starpilot/aethergauge.py b/selfdrive/ui/onroad/starpilot/aethergauge.py index 32ff7f0ee..b76cd5b79 100644 --- a/selfdrive/ui/onroad/starpilot/aethergauge.py +++ b/selfdrive/ui/onroad/starpilot/aethergauge.py @@ -385,6 +385,13 @@ class AetherGauge: self._last_active_time = 0.0 self._cooldown = 0.5 + def has_active_source(self) -> bool: + """Lightweight visibility check — no side effects, no data construction.""" + for is_active, _ in self._sources: + if is_active(): + return True + return False + def get_active_data(self) -> AetherGaugeData | None: now = rl.get_time() best_priority = 999 diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py index c8628356a..c138cbe64 100644 --- a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -1,8 +1,8 @@ import math +from typing import Optional import pyray as rl from openpilot.common.constants import CV -from openpilot.selfdrive.ui import UI_BORDER_SIZE 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 @@ -11,9 +11,6 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached # ── Constants ───────────────────────────────────────────────────────── # Set speed rect layout (from hud_renderer.py UI_CONFIG) -SET_SPEED_X_OFFSET = 60 -SET_SPEED_Y_OFFSET = 45 -SET_SPEED_HEIGHT = 204 SET_SPEED_WIDTH_IMP = 172 SET_SPEED_WIDTH_MET = 200 @@ -31,11 +28,6 @@ RED_RING_WIDTH = 20 # Pending sign blink cadence — 1s period, 50% duty cycle. PENDING_BLINK_MS = 500 -# Sources panel -SOURCE_ROW_W = 450 -SOURCE_ROW_H = 60 -SOURCE_ROW_GAP = UI_BORDER_SIZE // 2 - # Source display metadata: title, abbreviation, raw-value key (display order). SOURCE_DEFS = [ ("Dashboard", "Dash", "dashboard_sl"), @@ -182,9 +174,6 @@ def _get_slc_state(): 'use_vienna': ui_state.params.get_bool("UseVienna"), 'offset_str': offset_str, 'speed_conversion': speed_conversion, - 'show_sources': ui_state.params.get_bool("SpeedLimitSources"), - 'abbreviated': ui_state.params.get_bool("SLCAbbreviatedSources"), - 'active_only': ui_state.params.get_bool("SLCActiveSourcesOnly"), 'speed_unit': " km/h" if ui_state.is_metric else " mph", # Per-source raw values 'dashboard_sl': max(0.0, dashboard_sl * speed_conversion), @@ -366,22 +355,25 @@ def _draw_sign(state: dict, rect: rl.Rectangle, *, pending: bool = False): _SOURCE_ABBREV = {"Dashboard": "DASH", "Map Data": "MAPS", "Vision": "VISION", "Mapbox": "MAPB", "Upcoming": "NAV"} -def _draw_active_source_label(state: dict, cx: float, bottom_y: float): - """Draw the single active-source indicator below the sign when sources panel is off.""" +def _draw_active_source_label(state: dict, cx: float, bottom_y: float, expanded: bool = False) -> Optional[rl.Rectangle]: + """Draw the single active-source pill below the sign. Returns pill rect for hit-testing.""" source = state.get('speed_limit_source') if not source or source == "None" or source == "": - return + return None label = _SOURCE_ABBREV.get(source, source.upper()) + indicator = " \u25bc" if expanded else " \u25b6" + full_label = label + indicator font = _get_semi_bold() font_size = 20 - sz = measure_text_cached(font, label, font_size) + sz = measure_text_cached(font, full_label, font_size) rect = rl.Rectangle(cx - sz.x / 2 - 8, bottom_y + 8, sz.x + 16, font_size + 8) rl.draw_rectangle_rounded(rect, 0.4, 8, rl.Color(0, 0, 0, 180)) rl.draw_rectangle_rounded_lines_ex(rect, 0.4, 8, 1, rl.Color(255, 255, 255, 100)) - rl.draw_text_ex(font, label, rl.Vector2(cx - sz.x / 2, bottom_y + 12), font_size, 0, rl.WHITE) + rl.draw_text_ex(font, full_label, rl.Vector2(cx - sz.x / 2, bottom_y + 12), font_size, 0, rl.WHITE) + return rect -# ── Sources Panel ───────────────────────────────────────────────────── +# ── Sources Bubble (expandable overlay) ──────────────────────────────── def _draw_text_outlined(font, text: str, pos: rl.Vector2, font_size: int, fill: rl.Color, outline: rl.Color): """Draw text with a black outline stroke for legibility on colored fills.""" @@ -391,142 +383,106 @@ def _draw_text_outlined(font, text: str, pos: rl.Vector2, font_size: int, fill: rl.draw_text_ex(font, text, pos, font_size, 0, fill) -def _draw_sources_panel(state: dict, panel_x: float, panel_y: float, sign_width: float): - """Draw the source indicator rows below the speed limit sign.""" +_BUBBLE_PAD_X = 24 +_BUBBLE_ROW_H = 48 +_BUBBLE_GAP = 8 +_BUBBLE_FONT = 28 +_BUBBLE_BG = rl.Color(0, 0, 0, 200) +_BUBBLE_BORDER = rl.Color(255, 255, 255, 80) +_BUBBLE_ACCENT = rl.Color(201, 34, 49, 255) + +def _draw_sources_bubble(state: dict, anchor_rect: rl.Rectangle, sign_right: float): + """Draw the expanded sources bubble to the right of the anchor pill.""" font_bold = _get_bold() font_semi = _get_semi_bold() active_source = state['speed_limit_source'] - abbreviated = state['abbreviated'] - active_only = state['active_only'] - speed_unit = state['speed_unit'] - # Rect width: abbreviated mode sizes to content; full mode uses SOURCE_ROW_W. - rect_w = sign_width if abbreviated else SOURCE_ROW_W - - if abbreviated: - # Pre-compute the widest visible row label so all boxes share the same width. - max_w = rect_w - for _title, abbrev, value_key in SOURCE_DEFS: - value = state[value_key] - if active_only and value == 0: - continue - label = f"{abbrev}-{int(round(value))}" if value != 0 else f"{abbrev}-na" - needed = measure_text_cached(font_semi, label, 35).x + 40 - if needed > max_w: - max_w = needed - rect_w = max_w - - y = panel_y + rows = [] for title, abbrev, value_key in SOURCE_DEFS: value = state[value_key] - if active_only and value == 0: + if value == 0: continue + rows.append((title, abbrev, value, active_source == title and value != 0)) - is_active = active_source == title and value != 0 - - rect = rl.Rectangle(panel_x, y, rect_w, SOURCE_ROW_H) - - if is_active: - bg = rl.Color(201, 34, 49, 166) - border = rl.Color(201, 34, 49, 255) - text_font = font_bold - else: - bg = rl.Color(0, 0, 0, 166) - border = rl.Color(0, 0, 0, 255) - text_font = font_semi - - rl.draw_rectangle_rounded(rect, 24 / SOURCE_ROW_H, 16, bg) - rl.draw_rectangle_rounded_lines_ex(rect, 24 / SOURCE_ROW_H, 16, 10, border) - - if abbreviated: - full_text = f"{abbrev}-{int(round(value))}" if value != 0 else f"{abbrev}-na" - else: - speed_text = f"{int(round(value))}{speed_unit}" if value != 0 else "N/A" - full_text = f"{tr(title)} - {speed_text}" - - text_pos = rl.Vector2(rect.x + 20, rect.y + (SOURCE_ROW_H - 35) / 2) - if is_active: - # Active row: bold text with black outline stroke - _draw_text_outlined(text_font, full_text, text_pos, 35, rl.Color(255, 255, 255, 255), rl.Color(0, 0, 0, 255)) - else: - rl.draw_text_ex(text_font, full_text, text_pos, 35, 0, rl.Color(255, 255, 255, 255)) - - y += SOURCE_ROW_H + SOURCE_ROW_GAP - - -# ── Sign Rect Calculation ───────────────────────────────────────────── - -def _calc_sign_rect(sign_x: float, sign_y: float, sign_width: float, use_vienna: bool) -> rl.Rectangle: - """Compute the rect the sign (pending or active) is drawn in.""" - if use_vienna: - return rl.Rectangle(sign_x, sign_y, EU_SIGN_SIZE, EU_SIGN_SIZE) - return rl.Rectangle(sign_x, sign_y, sign_width, US_SIGN_HEIGHT) - - -# ── Click Handling ──────────────────────────────────────────────────── - -def handle_slc_click(mouse_pos, sign_x: float, sign_y: float, sign_width: float): - """Handle a click on a pending (unconfirmed) speed limit sign to accept it. - - Only fires when `speedLimitChanged && unconfirmedSpeedLimitValid`; the click - target is the same rect the pending sign is drawn in. - """ - state = _get_slc_state() - if state is None or not (state['speed_limit_changed'] and state['unconfirmed_valid']): + if not rows: return - use_vienna = state['use_vienna'] - sign_rect = _calc_sign_rect(sign_x, sign_y, sign_width, use_vienna) - if (sign_rect.x <= mouse_pos.x <= sign_rect.x + sign_rect.width and - sign_rect.y <= mouse_pos.y <= sign_rect.y + sign_rect.height): - from openpilot.common.params import Params - Params(memory=True).put_bool("SpeedLimitAccepted", True) + max_w = 0 + for _, abbrev, value, _ in rows: + label = f"{abbrev} {int(round(value))}" + needed = measure_text_cached(font_bold, label, _BUBBLE_FONT).x + _BUBBLE_PAD_X + if needed > max_w: + max_w = needed + + bubble_w = max_w + bubble_h = len(rows) * _BUBBLE_ROW_H + (len(rows) - 1) * _BUBBLE_GAP + 16 + + bubble_x = max(anchor_rect.x + anchor_rect.width + 12, sign_right + 6) + bubble_y = anchor_rect.y + anchor_rect.height / 2 - bubble_h / 2 + + bg_rect = rl.Rectangle(bubble_x, bubble_y, bubble_w, bubble_h) + rl.draw_rectangle_rounded(bg_rect, 0.25, 8, _BUBBLE_BG) + rl.draw_rectangle_rounded_lines_ex(bg_rect, 0.25, 8, 1, _BUBBLE_BORDER) + + arrow_y = int(anchor_rect.y + anchor_rect.height / 2) + rl.draw_triangle( + rl.Vector2(bubble_x, arrow_y - 6), + rl.Vector2(bubble_x, arrow_y + 6), + rl.Vector2(bubble_x - 6, arrow_y), + _BUBBLE_BG, + ) + + y = bubble_y + 8 + for title, abbrev, value, is_active in rows: + if is_active: + rl.draw_rectangle(int(bubble_x), int(y), 4, _BUBBLE_ROW_H, _BUBBLE_ACCENT) + + text_font = font_bold if is_active else font_semi + abbrev_text = abbrev + value_text = str(int(round(value))) + + abbrev_size = measure_text_cached(text_font, abbrev_text, _BUBBLE_FONT) + value_size = measure_text_cached(text_font, value_text, _BUBBLE_FONT) + + abbrev_pos = rl.Vector2(bubble_x + 16, y + (_BUBBLE_ROW_H - _BUBBLE_FONT) / 2) + value_pos = rl.Vector2(bubble_x + bubble_w - 12 - value_size.x, y + (_BUBBLE_ROW_H - _BUBBLE_FONT) / 2) + + text_color = rl.WHITE if is_active else rl.Color(255, 255, 255, 180) + + if is_active: + _draw_text_outlined(text_font, abbrev_text, abbrev_pos, _BUBBLE_FONT, text_color, rl.Color(0, 0, 0, 255)) + _draw_text_outlined(text_font, value_text, value_pos, _BUBBLE_FONT, text_color, rl.Color(0, 0, 0, 255)) + else: + rl.draw_text_ex(text_font, abbrev_text, abbrev_pos, _BUBBLE_FONT, 0, text_color) + rl.draw_text_ex(text_font, value_text, value_pos, _BUBBLE_FONT, 0, text_color) + + y += _BUBBLE_ROW_H + _BUBBLE_GAP # ── Public API ──────────────────────────────────────────────────────── -def render_speed_limit(content_rect: rl.Rectangle): - """Render SLC speed limit signs. Call from the onroad view render loop. - - Flow: - 1. If a pending (unconfirmed) limit is active, draw the PENDING sign in the - main sign rect and skip the regular sign. - 2. Otherwise, draw the regular sign (unless hidden by `HideSpeedLimit`). - 3. The sources panel appears only when `SpeedLimitSources` is on AND there is - no pending flash. - """ - state = _get_slc_state() - if state is None: - return - - # Anchor the sign rect to the set-speed box (X offset 60, Y offset 45) and - # stack it below that box with a SIGN_MARGIN inset on each side. - ss_width = SET_SPEED_WIDTH_MET if ui_state.is_metric else SET_SPEED_WIDTH_IMP - ss_x = content_rect.x + SET_SPEED_X_OFFSET + (SET_SPEED_WIDTH_IMP - ss_width) // 2 - ss_y = content_rect.y + SET_SPEED_Y_OFFSET - - sign_width = ss_width - 2 * SIGN_MARGIN - sign_x = ss_x + SIGN_MARGIN - sign_y = ss_y + SET_SPEED_HEIGHT + SIGN_MARGIN - - use_vienna = state['use_vienna'] - sign_rect = _calc_sign_rect(sign_x, sign_y, sign_width, use_vienna) - +def render_speed_limit_at(state: dict, rect: rl.Rectangle, expanded: bool = False) -> Optional[rl.Rectangle]: + """Render SLC speed limit signs at a specific rect calculated by the layout manager. + Returns the pill rect for hit-testing, or None if no pill was drawn.""" flashing_pending = state['speed_limit_changed'] and state['unconfirmed_valid'] if flashing_pending: - _draw_sign(state, sign_rect, pending=True) - elif not state['hide']: - _draw_sign(state, sign_rect, pending=False) + _draw_sign(state, rect, pending=True) + return None - # Single active-source label below sign when sources panel is OFF - if not state['show_sources']: - cx_center = sign_x + (EU_SIGN_SIZE if use_vienna else sign_width) / 2 - bottom_y = sign_y + (EU_SIGN_SIZE if use_vienna else US_SIGN_HEIGHT) - _draw_active_source_label(state, cx_center, bottom_y) + if state['hide']: + return None + + _draw_sign(state, rect, pending=False) + + use_vienna = state['use_vienna'] + cx_center = rect.x + rect.width / 2 + sign_h = EU_SIGN_SIZE if use_vienna else US_SIGN_HEIGHT + bottom_y = rect.y + sign_h + pill_rect = _draw_active_source_label(state, cx_center, bottom_y, expanded) + + if expanded and pill_rect: + _draw_sources_bubble(state, pill_rect, rect.x + rect.width) + + return pill_rect - # Sources panel (only when SpeedLimitSources is on AND no pending flash) - if state['show_sources'] and not flashing_pending: - sources_x = sign_rect.x - SIGN_MARGIN - sources_y = sign_rect.y + sign_rect.height + UI_BORDER_SIZE - _draw_sources_panel(state, sources_x, sources_y, sign_width) diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 53a2857ba..fcfcde64d 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -5,38 +5,77 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.onroad.starpilot.starpilot_border import render_behind, render_overlay, render_background_effects from openpilot.selfdrive.ui.onroad.starpilot.path import render_adjacent_lanes, render_path_edges -from openpilot.selfdrive.ui.onroad.starpilot.personality_button import PersonalityButton, BTN_SIZE -from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import ( - render_speed_limit, handle_slc_click, SET_SPEED_X_OFFSET, SET_SPEED_Y_OFFSET, - SET_SPEED_WIDTH_IMP, SET_SPEED_WIDTH_MET, SET_SPEED_HEIGHT, SIGN_MARGIN, -) from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import AetherGauge from openpilot.selfdrive.ui.onroad.starpilot.torque_bar import TorqueBar -from openpilot.selfdrive.ui.lib.starpilot_status import ( - get_screen_edge_color, ENGAGED_COLOR, - EXPERIMENTAL_COLOR, TRAFFIC_COLOR, +from openpilot.selfdrive.ui.onroad.starpilot.widget_layout_manager import WidgetLayoutManager +from openpilot.selfdrive.ui.onroad.starpilot.widgets import ( + SetSpeedWidget, SpeedLimitWidget, PedalIconsWidget, + AetherGaugeWidget, PersonalityButtonWidget, DriverMonitorWidget, + SteeringWheelWidget ) -from cereal import log +from openpilot.selfdrive.ui.onroad.starpilot.stopping_point import render_stopping_point +from openpilot.selfdrive.ui.onroad.starpilot.pause_indicators import render_lateral_paused, render_longitudinal_paused +from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_icon +from openpilot.selfdrive.ui.lib.starpilot_status import ( + get_screen_edge_color, ENGAGED_COLOR, + EXPERIMENTAL_COLOR, TRAFFIC_COLOR, +) + from openpilot.system.ui.lib.application import MousePos, gui_app, FontWeight from openpilot.system.ui.lib.text_measure import measure_text_cached +from cereal import log AlertSize = log.SelfdriveState.AlertSize + class StarPilotOnroadView(AugmentedRoadView): def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): super().__init__(stream_type) self._params = Params() - self._personality_button = PersonalityButton() self._font_bold = gui_app.font(FontWeight.BOLD) self._font_medium = gui_app.font(FontWeight.MEDIUM) self._standstill_started_at = 0.0 - self._aethergauge = AetherGauge() self._torque_bar = TorqueBar() + self._min_fps = 99.9 + self._max_fps = 0.0 + self._avg_fps = 0.0 + + self.layout_manager = WidgetLayoutManager(self._content_rect) + + # Disable parent rendering calls — layout manager draws at computed bounds + self._draw_driver_state = False + self._hud_renderer.draw_set_speed = False + self._hud_renderer.draw_exp_button = False + + # Initialize layout widgets + self._set_speed_widget = SetSpeedWidget(self._hud_renderer) + self._speed_limit_widget = SpeedLimitWidget() + self._aethergauge_widget = AetherGaugeWidget(self._hud_renderer) + self._steering_wheel_widget = SteeringWheelWidget(self._hud_renderer._exp_button) + self._pedals_widget = PedalIconsWidget() + self._personality_button_widget = PersonalityButtonWidget() + self._driver_monitor_widget = DriverMonitorWidget(self.driver_state_renderer) + + # Register to layout zones + self.layout_manager.register_widget("left", self._set_speed_widget) + self.layout_manager.register_widget("left", self._speed_limit_widget) + self.layout_manager.register_widget("left", self._aethergauge_widget) + self.layout_manager.register_widget("right", self._steering_wheel_widget) + self.layout_manager.register_widget("right", self._pedals_widget) + self.layout_manager.register_widget("bottom", self._personality_button_widget) + self.layout_manager.register_widget("bottom", self._driver_monitor_widget) + + # Register as child widgets for click propagation + self._child(self._set_speed_widget) + self._child(self._speed_limit_widget) + self._child(self._aethergauge_widget) + self._child(self._steering_wheel_widget) + self._child(self._pedals_widget) + self._child(self._personality_button_widget) + self._child(self._driver_monitor_widget) def _render(self, rect: rl.Rectangle): - self._position_personality_button() border_width = self._get_border_width() border_color = get_screen_edge_color(ui_state) rl.draw_rectangle_rounded(rect, 0.12, 10, border_color) @@ -47,49 +86,43 @@ class StarPilotOnroadView(AugmentedRoadView): return if self._draw_hud_controls: + dm = self.driver_state_renderer + self.layout_manager.update_layout(self._content_rect, is_rhd=dm.is_rhd if dm else False) self._render_slc() self._render_overlays() self._render_road_name() if self._draw_road_overlays: self._render_path_features(rect) - def _render_slc(self): - alert_showing, _ = self.alert_renderer.will_render() - if alert_showing is not None and alert_showing.size == AlertSize.full: - return + def _draw_border(self, rect: rl.Rectangle): + border_width = self._get_border_width() + rl.draw_rectangle_rounded_lines_ex(rect, 0.12, 10, border_width, rl.BLACK) + border_rect = rl.Rectangle(rect.x + border_width, rect.y + border_width, + rect.width - 2 * border_width, rect.height - 2 * border_width) + render_behind(border_rect, border_width) + render_overlay(border_rect, border_width) - rl.begin_scissor_mode( - int(self._content_rect.x), int(self._content_rect.y), - int(self._content_rect.width), int(self._content_rect.height), - ) - render_speed_limit(self._content_rect) - rl.end_scissor_mode() + def _render_slc(self): + alert_showing, alert_size = self.alert_renderer.will_render() + if alert_showing is not None and alert_size == AlertSize.full: + return + if self._speed_limit_widget.is_visible: + self._speed_limit_widget.render(self._speed_limit_widget.rect) + if self._set_speed_widget.is_visible: + self._set_speed_widget.render(self._set_speed_widget.rect) def _render_overlays(self): alert_showing, _ = self.alert_renderer.will_render() if alert_showing is not None: return - self._position_personality_button() - self._personality_button.render() self._render_standstill_timer() self._render_developer_metrics() - dm = self.driver_state_renderer - if dm and dm.position_x != 0.0: - cx = dm.position_x - btn = self._personality_button - if btn.is_visible and btn.center_x < cx: - cx = btn.center_x - self._aethergauge.render( - self._content_rect, self._font_bold, self._font_medium, - current_speed=self._hud_renderer.speed, - cx=cx, bottom=dm.position_y - 96 - 145 - ) - else: - self._aethergauge.render(self._content_rect, self._font_bold, self._font_medium, current_speed=self._hud_renderer.speed) + + self.layout_manager.render_widgets(exclude={"speed_limit", "set_speed"}) + self._render_torque_bar() self._render_bottom_row_widgets() - self._render_pedals() def _render_torque_bar(self) -> None: """Draw the curved torque-utilization indicator at the bottom of the screen.""" @@ -113,8 +146,8 @@ class StarPilotOnroadView(AugmentedRoadView): return rl.begin_scissor_mode( - int(self._content_rect.x), int(self._content_rect.y), - int(self._content_rect.width), int(self._content_rect.height), + int(rect.x), int(rect.y), + int(rect.width), int(rect.height), ) # Path edges (always rendered if track_edge_vertices exist) @@ -125,38 +158,10 @@ class StarPilotOnroadView(AugmentedRoadView): render_adjacent_lanes(mr) # Render stopping point atop the path - from openpilot.selfdrive.ui.onroad.starpilot.stopping_point import render_stopping_point render_stopping_point(mr, self._font_bold) rl.end_scissor_mode() - def _position_personality_button(self): - dm = self.driver_state_renderer - toggle_on = self._params.get_bool("OnroadDistanceButton") - GAP = 10 - - if dm and dm.position_x != 0.0: - unshifted = dm.position_x - dm.x_shift - y = dm.position_y - BTN_SIZE / 2 - - if dm.is_rhd: - x = dm.position_x - BTN_SIZE * 2 - else: - x = unshifted - BTN_SIZE // 2 - dm.x_shift = BTN_SIZE + GAP if (dm.is_visible and toggle_on) else 0.0 - - self._personality_button.set_position(x, y) - - if not dm or not dm.is_visible or not toggle_on: - self._personality_button.set_visible(False) - if dm and not dm.is_rhd: - dm.x_shift = 0.0 - return - - self._personality_button.set_visible( - lambda: ui_state.started and ui_state.has_longitudinal_control - ) - def _render_standstill_timer(self): if not self._params.get_bool("stopped_timer"): self._standstill_started_at = 0.0 @@ -186,10 +191,8 @@ class StarPilotOnroadView(AugmentedRoadView): minute_size = measure_text_cached(self._font_bold, minute_text, 176) second_size = measure_text_cached(self._font_medium, second_text, 66) - import numpy as np - def blend_colors(start: rl.Color, end: rl.Color, transition: float) -> rl.Color: - transition = float(np.clip(transition, 0.0, 1.0)) + transition = float(min(max(transition, 0.0), 1.0)) return rl.Color( int(start.r + transition * (end.r - start.r)), int(start.g + transition * (end.g - start.g)), @@ -224,31 +227,12 @@ class StarPilotOnroadView(AugmentedRoadView): rl.Color(255, 255, 255, 242), ) - def _draw_border(self, rect: rl.Rectangle): - border_width = self._get_border_width() - rl.draw_rectangle_rounded_lines_ex(rect, 0.12, 10, border_width, rl.BLACK) - border_rect = rl.Rectangle(rect.x + border_width, rect.y + border_width, - rect.width - 2 * border_width, rect.height - 2 * border_width) - - render_behind(border_rect, border_width) - - render_overlay(border_rect, border_width) - def _handle_mouse_press(self, mouse_pos: MousePos): - border_width = self._get_border_width() - content_rect = rl.Rectangle( - border_width, border_width, - gui_app.width - 2 * border_width, gui_app.height - 2 * border_width, - ) - ss_width = SET_SPEED_WIDTH_MET if ui_state.is_metric else SET_SPEED_WIDTH_IMP - ss_x = content_rect.x + SET_SPEED_X_OFFSET + (SET_SPEED_WIDTH_IMP - ss_width) // 2 - sign_x = ss_x + SIGN_MARGIN - sign_y = content_rect.y + SET_SPEED_Y_OFFSET + SET_SPEED_HEIGHT + SIGN_MARGIN - sign_width = ss_width - 2 * SIGN_MARGIN - handle_slc_click(mouse_pos, sign_x, sign_y, sign_width) - - if self._personality_button.is_interacting: - return + # 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 rl.check_collision_point_rec(mouse_pos, widget.rect): + return super()._handle_mouse_press(mouse_pos) def _render_developer_metrics(self): @@ -279,10 +263,6 @@ class StarPilotOnroadView(AugmentedRoadView): # Track FPS fps = rl.get_fps() - if not hasattr(self, "_min_fps"): - self._min_fps = 99.9 - self._max_fps = 0.0 - self._avg_fps = 0.0 if fps > 0: self._min_fps = min(self._min_fps, fps) @@ -385,10 +365,8 @@ class StarPilotOnroadView(AugmentedRoadView): badge_rect = rl.Rectangle(bx, by, badge_w, badge_h) if badge == "lateral_paused": - from openpilot.selfdrive.ui.onroad.starpilot.pause_indicators import render_lateral_paused render_lateral_paused(badge_rect) elif badge == "longitudinal_paused": - from openpilot.selfdrive.ui.onroad.starpilot.pause_indicators import render_longitudinal_paused render_longitudinal_paused(badge_rect) # 2. Render Weather (on the opposite side of DM icon) @@ -405,25 +383,8 @@ class StarPilotOnroadView(AugmentedRoadView): cy = dm.position_y - weather_h / 2 weather_rect = rl.Rectangle(wx, cy, weather_w, weather_h) - from openpilot.selfdrive.ui.onroad.starpilot.weather_icon import render_weather_icon render_weather_icon(weather_rect) - def _render_pedals(self): - alert_showing, _ = self.alert_renderer.will_render() - if alert_showing is not None: - return - - dm = self.driver_state_renderer - if not dm or dm.position_x == 0.0: - return - - anchor = dm.position_x if dm.is_rhd else dm.position_x - dm.x_shift - start_x = anchor - 96 - start_y = dm.position_y - 198 - - from openpilot.selfdrive.ui.onroad.starpilot.pedal_icons import render_pedal_icons - render_pedal_icons(start_x, start_y, self._font_bold) - def _render_road_name(self): toggles = ui_state.starpilot_toggles road_name_on = bool(toggles.get("road_name_ui", self._params.get_bool("RoadNameUI"))) diff --git a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py new file mode 100644 index 000000000..7d1513fde --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py @@ -0,0 +1,79 @@ +import pyray as rl +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget + +class WidgetLayoutManager: + def __init__(self, content_rect: rl.Rectangle): + self.content_rect = content_rect + self.zones = { + "left": [], + "bottom": [], + "right": [] + } + self.spacing = 15 # Spacing between widgets + + def register_widget(self, zone: str, widget: LayoutWidget): + """Register a widget in a specific zone.""" + self.zones[zone].append(widget) + self.zones[zone].sort(key=lambda w: w.priority) + + def update_layout(self, content_rect: rl.Rectangle, is_rhd: bool = False): + """Calculate and apply positions of all active widgets in all zones.""" + self.content_rect = content_rect + self._layout_left() + self._layout_bottom(is_rhd) + self._layout_right() + + def _layout_left(self): + active_widgets = [w for w in self.zones["left"] if w.is_visible] + + # Left zone stacks vertically from the top-left offset + # X anchor is set speed box center (around 160 pixels from left edge) + center_x = self.content_rect.x + 60 + 172 // 2 # default set_speed center + current_y = self.content_rect.y + 45 + + for widget in active_widgets: + w, h = widget.get_size() + widget.set_rect(rl.Rectangle(center_x - w / 2, current_y, w, h)) + current_y += h + self.spacing + + def _layout_bottom(self, is_rhd: bool): + active_widgets = [w for w in self.zones["bottom"] if w.is_visible] + if not active_widgets: + return + + # Total width of active widgets including spacing + total_w = sum(w.get_size()[0] for w in active_widgets) + self.spacing * (len(active_widgets) - 1) + bottom_y = self.content_rect.y + self.content_rect.height - 146 + + if not is_rhd: + # LHD: stack from left to right starting at x = 146 + current_x = self.content_rect.x + 146 + for widget in active_widgets: + w, h = widget.get_size() + widget.set_rect(rl.Rectangle(current_x - w / 2, bottom_y - h / 2, w, h)) + current_x += w + self.spacing + else: + # RHD: stack from left to right starting such that the last widget is at width - 146 + current_x = self.content_rect.x + self.content_rect.width - 146 - (total_w - active_widgets[-1].get_size()[0]) + for widget in active_widgets: + w, h = widget.get_size() + widget.set_rect(rl.Rectangle(current_x - w / 2, bottom_y - h / 2, w, h)) + current_x += w + self.spacing + + def _layout_right(self): + active_widgets = [w for w in self.zones["right"] if w.is_visible] + center_x = self.content_rect.x + self.content_rect.width - 146 + current_y = self.content_rect.y + 45 + for widget in active_widgets: + w, h = widget.get_size() + widget.set_rect(rl.Rectangle(center_x - w / 2, current_y, w, h)) + current_y += h + self.spacing + + def render_widgets(self, exclude: set[str] | None = None): + """Render all visible registered widgets in their layout positions.""" + skip = exclude or set() + for zone in self.zones.values(): + for widget in zone: + if widget.is_visible and widget.name not in skip: + widget.render(widget.rect) + diff --git a/selfdrive/ui/onroad/starpilot/widgets/__init__.py b/selfdrive/ui/onroad/starpilot/widgets/__init__.py new file mode 100644 index 000000000..1f1518689 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/__init__.py @@ -0,0 +1,19 @@ +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.set_speed import SetSpeedWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.speed_limit import SpeedLimitWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.pedal_icons import PedalIconsWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.aethergauge import AetherGaugeWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.personality_button import PersonalityButtonWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.driver_monitor import DriverMonitorWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.steering_wheel import SteeringWheelWidget + +__all__ = [ + "LayoutWidget", + "SetSpeedWidget", + "SpeedLimitWidget", + "PedalIconsWidget", + "AetherGaugeWidget", + "PersonalityButtonWidget", + "DriverMonitorWidget", + "SteeringWheelWidget", +] diff --git a/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py b/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py new file mode 100644 index 000000000..b1e053b4d --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py @@ -0,0 +1,31 @@ +import pyray as rl +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import AetherGauge + +class AetherGaugeWidget(LayoutWidget): + def __init__(self, hud_renderer): + super().__init__("aethergauge", priority=3) + self.hud_renderer = hud_renderer + self._aethergauge = AetherGauge() + self._font_bold = gui_app.font(FontWeight.BOLD) + self._font_medium = gui_app.font(FontWeight.MEDIUM) + + @property + def is_visible(self) -> bool: + return self._aethergauge.has_active_source() + + def get_size(self) -> tuple[float, float]: + # Width 180, height 250 covers the road visual and the mini text cradle + return 180.0, 250.0 + + def _render(self, rect: rl.Rectangle) -> None: + cx = rect.x + rect.width / 2 + # Set the road bottom to rect.y + 130, leaving 120px for the text cradle underneath + bottom = rect.y + 130 + self._aethergauge.render( + rect, self._font_bold, self._font_medium, + current_speed=self.hud_renderer.speed, + cx=cx, + bottom=bottom + ) diff --git a/selfdrive/ui/onroad/starpilot/widgets/base.py b/selfdrive/ui/onroad/starpilot/widgets/base.py new file mode 100644 index 000000000..2a3db7825 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/base.py @@ -0,0 +1,23 @@ +import pyray as rl +from abc import abstractmethod +from openpilot.system.ui.widgets import Widget + +class LayoutWidget(Widget): + def __init__(self, name: str, priority: int): + super().__init__() + self.name = name + self.priority = priority + + @property + @abstractmethod + def is_visible(self) -> bool: + """Returns True if the widget should currently be displayed.""" + + @abstractmethod + def get_size(self) -> tuple[float, float]: + """Returns the width and height of the widget as (width, height).""" + + 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 + pass diff --git a/selfdrive/ui/onroad/starpilot/widgets/driver_monitor.py b/selfdrive/ui/onroad/starpilot/widgets/driver_monitor.py new file mode 100644 index 000000000..d972a8254 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/driver_monitor.py @@ -0,0 +1,35 @@ +import pyray as rl +from cereal import log, custom +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer + +AlertSize = log.SelfdriveState.AlertSize + + +class DriverMonitorWidget(LayoutWidget): + def __init__(self, renderer: DriverStateRenderer): + super().__init__("driver_monitor", priority=2) + self._renderer = renderer + self._child(self._renderer) + self._renderer.auto_position = False + + @property + def is_visible(self) -> bool: + sm = ui_state.sm + alert_ok = sm["selfdriveState"].alertSize == AlertSize.none + if alert_ok: + starpilot_ss = sm["starpilotSelfdriveState"] if sm.valid.get("starpilotSelfdriveState", False) else None + if starpilot_ss: + alert_ok = starpilot_ss.alertSize == custom.StarPilotSelfdriveState.AlertSize.none + data_fresh = sm.recv_frame["driverStateV2"] > ui_state.started_frame + not_hidden = not ui_state.starpilot_toggles.get("hide_dm_icon", False) + return bool(alert_ok and data_fresh and not_hidden) + + def get_size(self) -> tuple[float, float]: + return 192.0, 192.0 + + def _render(self, rect: rl.Rectangle) -> None: + self._renderer.position_x = rect.x + rect.width / 2 + self._renderer.position_y = rect.y + rect.height / 2 + self._renderer.render(rect) diff --git a/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py b/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py new file mode 100644 index 000000000..94a6ef06e --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/pedal_icons.py @@ -0,0 +1,27 @@ +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.starpilot.pedal_icons import render_pedal_icons + + +class PedalIconsWidget(LayoutWidget): + def __init__(self): + super().__init__("pedal_icons", priority=2) + self._font_bold = gui_app.font(FontWeight.BOLD) + + @property + def is_visible(self) -> bool: + params = ui_state.params + if not params.get_bool("PedalsOnUI"): + return False + # Only render when car state is valid + return "carState" in ui_state.sm.valid and ui_state.sm.valid["carState"] + + def get_size(self) -> tuple[float, float]: + # Width is 180 (two circles of radius 36 centered at 48 and 144) + # Height is 96 (center 48 + radius 36 + padding) + return 180.0, 96.0 + + def _render(self, rect: rl.Rectangle) -> None: + render_pedal_icons(rect.x, rect.y, self._font_bold) diff --git a/selfdrive/ui/onroad/starpilot/widgets/personality_button.py b/selfdrive/ui/onroad/starpilot/widgets/personality_button.py new file mode 100644 index 000000000..05b809536 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/personality_button.py @@ -0,0 +1,23 @@ +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.starpilot.personality_button import PersonalityButton + +class PersonalityButtonWidget(LayoutWidget): + def __init__(self): + super().__init__("personality_button", priority=1) + self._button = PersonalityButton() + self._button.set_visible(True) + self._child(self._button) + + @property + def is_visible(self) -> bool: + toggle_on = ui_state.params.get_bool("OnroadDistanceButton") + return bool(toggle_on and ui_state.started and ui_state.has_longitudinal_control) + + def get_size(self) -> tuple[float, float]: + return 192.0, 192.0 + + def _render(self, rect: rl.Rectangle) -> None: + # Render the child button within the layout rect + self._button.render(rect) diff --git a/selfdrive/ui/onroad/starpilot/widgets/set_speed.py b/selfdrive/ui/onroad/starpilot/widgets/set_speed.py new file mode 100644 index 000000000..9c6690f08 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/set_speed.py @@ -0,0 +1,72 @@ +import pyray as rl +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +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.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.hud_renderer import ( + UI_CONFIG, FONT_SIZES, COLORS, CRUISE_DISABLED_CHAR +) + +class SetSpeedWidget(LayoutWidget): + def __init__(self, hud_renderer): + super().__init__("set_speed", priority=1) + self.hud_renderer = hud_renderer + self._font_semi_bold = gui_app.font(FontWeight.SEMI_BOLD) + self._font_bold = gui_app.font(FontWeight.BOLD) + + @property + def is_visible(self) -> bool: + return ( + self.hud_renderer.is_cruise_available + and not ui_state.starpilot_toggles.get("hide_max_speed", False) + ) + + def get_size(self) -> tuple[float, float]: + set_speed_width = ( + UI_CONFIG.set_speed_width_metric + if ui_state.is_metric + else UI_CONFIG.set_speed_width_imperial + ) + return float(set_speed_width), float(UI_CONFIG.set_speed_height) + + def _render(self, rect: rl.Rectangle) -> None: + rl.draw_rectangle_rounded(rect, 0.35, 10, COLORS.BLACK_TRANSLUCENT) + rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 10, 6, COLORS.BORDER_TRANSLUCENT) + + max_color = COLORS.GREY + set_speed_color = COLORS.DARK_GREY + if self.hud_renderer.is_cruise_set: + set_speed_color = COLORS.WHITE + if ui_state.status == UIStatus.ENGAGED: + max_color = COLORS.ENGAGED + elif ui_state.status == UIStatus.DISENGAGED: + max_color = COLORS.DISENGAGED + elif ui_state.status == UIStatus.OVERRIDE: + max_color = COLORS.OVERRIDE + + max_text = tr("MAX") + max_text_width = measure_text_cached(self._font_semi_bold, max_text, FONT_SIZES.max_speed).x + rl.draw_text_ex( + self._font_semi_bold, + max_text, + rl.Vector2(rect.x + (rect.width - max_text_width) / 2, rect.y + 27), + FONT_SIZES.max_speed, + 0, + max_color, + ) + + set_speed_text = ( + CRUISE_DISABLED_CHAR + if not self.hud_renderer.is_cruise_set + else str(round(self.hud_renderer.set_speed)) + ) + speed_text_width = measure_text_cached(self._font_bold, set_speed_text, FONT_SIZES.set_speed).x + rl.draw_text_ex( + self._font_bold, + set_speed_text, + rl.Vector2(rect.x + (rect.width - speed_text_width) / 2, rect.y + 77), + FONT_SIZES.set_speed, + 0, + set_speed_color, + ) diff --git a/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py b/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py new file mode 100644 index 000000000..7fb1287a9 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py @@ -0,0 +1,63 @@ +import pyray as rl +from typing import Optional +from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import ( + _get_slc_state, render_speed_limit_at, SIGN_MARGIN, + EU_SIGN_SIZE, US_SIGN_HEIGHT, + SET_SPEED_WIDTH_MET, SET_SPEED_WIDTH_IMP +) + + +class SpeedLimitWidget(LayoutWidget): + def __init__(self): + super().__init__("speed_limit", priority=2) + self._slc_state: dict | None = None + self._pill_rect: Optional[rl.Rectangle] = None + self._expanded = False + + @property + def is_visible(self) -> bool: + self._slc_state = _get_slc_state() + if self._slc_state is None: + self._pill_rect = None + return False + flashing_pending = self._slc_state['speed_limit_changed'] and self._slc_state['unconfirmed_valid'] + return flashing_pending or not self._slc_state['hide'] + + def get_size(self) -> tuple[float, float]: + if self._slc_state is None: + return 0.0, 0.0 + + use_vienna = self._slc_state['use_vienna'] + ss_width = SET_SPEED_WIDTH_MET if ui_state.is_metric else SET_SPEED_WIDTH_IMP + sign_width = ss_width - 2 * SIGN_MARGIN + + w = float(EU_SIGN_SIZE if use_vienna else sign_width) + h = float(EU_SIGN_SIZE if use_vienna else US_SIGN_HEIGHT) + + flashing_pending = self._slc_state['speed_limit_changed'] and self._slc_state['unconfirmed_valid'] + if not flashing_pending: + source = self._slc_state.get('speed_limit_source') + if source and source != "None" and source != "": + h += 40.0 + + return w, h + + def _render(self, rect: rl.Rectangle) -> None: + if self._slc_state is None: + return + self._pill_rect = render_speed_limit_at(self._slc_state, rect, self._expanded) + + def _handle_mouse_press(self, mouse_pos) -> None: + if self._pill_rect and rl.check_collision_point_rec(mouse_pos, self._pill_rect): + self._expanded = not self._expanded + return + + state = _get_slc_state() + if state is None or not (state['speed_limit_changed'] and state['unconfirmed_valid']): + return + + if rl.check_collision_point_rec(mouse_pos, self.rect): + Params(memory=True).put_bool("SpeedLimitAccepted", True) diff --git a/selfdrive/ui/onroad/starpilot/widgets/steering_wheel.py b/selfdrive/ui/onroad/starpilot/widgets/steering_wheel.py new file mode 100644 index 000000000..918f06bc7 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/steering_wheel.py @@ -0,0 +1,19 @@ +import pyray as rl +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.exp_button import ExpButton + +class SteeringWheelWidget(LayoutWidget): + def __init__(self, exp_button: ExpButton): + super().__init__("steering_wheel", priority=1) + self._button = exp_button + self._child(self._button) + + @property + def is_visible(self) -> bool: + return bool(self._button.is_visible) + + def get_size(self) -> tuple[float, float]: + return 192.0, 192.0 + + def _render(self, rect: rl.Rectangle) -> None: + self._button.render(rect) diff --git a/selfdrive/ui/tests/test_widget_layout_manager.py b/selfdrive/ui/tests/test_widget_layout_manager.py new file mode 100644 index 000000000..177e8133b --- /dev/null +++ b/selfdrive/ui/tests/test_widget_layout_manager.py @@ -0,0 +1,228 @@ +import sys +import types +import unittest +from unittest.mock import MagicMock + +# Create minimal mocks for imports so the tests can run headlessly. +rl = types.SimpleNamespace( + Rectangle=lambda x=0, y=0, width=0, height=0: types.SimpleNamespace(x=x, y=y, width=width, height=height), + check_collision_point_rec=lambda p, r: (r.x <= p.x <= r.x + r.width) and (r.y <= p.y <= r.y + r.height), +) +sys.modules["pyray"] = rl + +ui_state = types.SimpleNamespace( + is_metric=True, + params=MagicMock(), +) +sys.modules["openpilot.selfdrive.ui.ui_state"] = types.SimpleNamespace(ui_state=ui_state) + +# Mock openpilot.system.ui.widgets +widgets_mod = types.ModuleType("openpilot.system.ui.widgets") +class MockWidget: + def __init__(self): + self.rect = rl.Rectangle() + self._children = [] + def _child(self, w): + self._children.append(w) + return w + def set_rect(self, rect): + self.rect = rect + def render(self, rect): + self.rect = rect +widgets_mod.Widget = MockWidget +sys.modules["openpilot.system.ui.widgets"] = widgets_mod + +# Expose concrete widgets module with a mock base class +widgets_starpilot_base = types.ModuleType("openpilot.selfdrive.ui.onroad.starpilot.widgets.base") +class LayoutWidget(MockWidget): + def __init__(self, name: str, priority: int): + super().__init__() + self.name = name + self.priority = priority +widgets_starpilot_base.LayoutWidget = LayoutWidget +sys.modules["openpilot.selfdrive.ui.onroad.starpilot.widgets.base"] = widgets_starpilot_base + + +# Now we can import our WidgetLayoutManager +from openpilot.selfdrive.ui.onroad.starpilot.widget_layout_manager import WidgetLayoutManager + + +class DummyLayoutWidget(LayoutWidget): + def __init__(self, name: str, priority: int, width: float, height: float, visible: bool = True): + super().__init__(name, priority) + self._width = width + self._height = height + self._visible = visible + + @property + def is_visible(self) -> bool: + return self._visible + + def get_size(self) -> tuple[float, float]: + return self._width, self._height + + +class TestWidgetLayoutManager(unittest.TestCase): + def setUp(self): + # Set up content rect matching screen resolution 2160x1080 inside borders + self.content_rect = rl.Rectangle(30, 30, 2100, 1020) + self.layout_manager = WidgetLayoutManager(self.content_rect) + + def test_priority_sorting(self): + w1 = DummyLayoutWidget("widget1", priority=3, width=100, height=100) + w2 = DummyLayoutWidget("widget2", priority=1, width=100, height=100) + w3 = DummyLayoutWidget("widget3", priority=2, width=100, height=100) + + self.layout_manager.register_widget("left", w1) + self.layout_manager.register_widget("left", w2) + self.layout_manager.register_widget("left", w3) + + # Should be sorted: w2 (1), w3 (2), w1 (3) + self.assertEqual(self.layout_manager.zones["left"][0], w2) + self.assertEqual(self.layout_manager.zones["left"][1], w3) + self.assertEqual(self.layout_manager.zones["left"][2], w1) + + def test_left_zone_vertical_stacking(self): + # Left zone widgets stack vertically + # Center X anchor should be rect.x + 60 + 172 // 2 = 30 + 60 + 86 = 176 + w1 = DummyLayoutWidget("set_speed", priority=1, width=200, height=204) + w2 = DummyLayoutWidget("speed_limit", priority=2, width=176, height=176) + + self.layout_manager.register_widget("left", w1) + self.layout_manager.register_widget("left", w2) + + self.layout_manager.update_layout(self.content_rect, is_rhd=False) + + # w1 should be at y = rect.y + 45 = 30 + 45 = 75 + # center is 176, so x = 176 - 200/2 = 76 + self.assertEqual(w1.rect.x, 76) + self.assertEqual(w1.rect.y, 75) + self.assertEqual(w1.rect.width, 200) + self.assertEqual(w1.rect.height, 204) + + # w2 should stack below w1: y = 75 + 204 + 15 (spacing) = 294 + # center is 176, so x = 176 - 176/2 = 88 + self.assertEqual(w2.rect.x, 88) + self.assertEqual(w2.rect.y, 294) + + def test_bottom_zone_lhd_stacking(self): + # Bottom zone: LHD horizontal stacking from left to right starting at x = 146 + # Y center should be content_rect.y + content_rect.height - 146 = 30 + 1020 - 146 = 904 + w1 = DummyLayoutWidget("personality", priority=1, width=192, height=192) + w2 = DummyLayoutWidget("driver_monitor", priority=2, width=192, height=192) + w3 = DummyLayoutWidget("third", priority=3, width=192, height=192) + + self.layout_manager.register_widget("bottom", w1) + self.layout_manager.register_widget("bottom", w2) + self.layout_manager.register_widget("bottom", w3) + + self.layout_manager.update_layout(self.content_rect, is_rhd=False) + + # w1 (first): center_x = content_rect.x + 146 = 30 + 146 = 176 + # so x = 176 - 192/2 = 80 + # y = 904 - 192/2 = 808 + self.assertEqual(w1.rect.x, 80) + self.assertEqual(w1.rect.y, 808) + + # w2 (second): center_x = 176 + 192 + 15 = 383 + # so x = 383 - 192/2 = 287 + self.assertEqual(w2.rect.x, 287) + self.assertEqual(w2.rect.y, 808) + + # w3 (third): center_x = 383 + 192 + 15 = 590 + # so x = 590 - 192/2 = 494 + self.assertEqual(w3.rect.x, 494) + self.assertEqual(w3.rect.y, 808) + + + def test_bottom_zone_rhd_stacking(self): + # Bottom zone: RHD horizontal stacking such that last widget center is at width - 146 + w1 = DummyLayoutWidget("personality", priority=1, width=192, height=192) + w2 = DummyLayoutWidget("driver_monitor", priority=2, width=192, height=192) + w3 = DummyLayoutWidget("third", priority=3, width=192, height=192) + + self.layout_manager.register_widget("bottom", w1) + self.layout_manager.register_widget("bottom", w2) + self.layout_manager.register_widget("bottom", w3) + + self.layout_manager.update_layout(self.content_rect, is_rhd=True) + + # Total width of widgets = 192 + 15 + 192 + 15 + 192 = 606 + # Last widget center should be width - 146 = 2130 - 146 = 1984 + # Middle widget (w2) center should be 1984 - 192 - 15 = 1777 + # First widget (w1) center should be 1777 - 192 - 15 = 1570 + + # w1: center_x = 1570, so x = 1570 - 192/2 = 1474 + self.assertEqual(w1.rect.x, 1474) + + # w2: center_x = 1777, so x = 1777 - 192/2 = 1681 + self.assertEqual(w2.rect.x, 1681) + + # w3: center_x = 1984, so x = 1984 - 192/2 = 1888 + self.assertEqual(w3.rect.x, 1888) + + def test_dynamic_visibility_shifting(self): + # Left zone: w1, w2, w3. w2 becomes invisible. w3 should shift up to occupy w2's space. + w1 = DummyLayoutWidget("w1", priority=1, width=100, height=100) + w2 = DummyLayoutWidget("w2", priority=2, width=100, height=100, visible=False) + w3 = DummyLayoutWidget("w3", priority=3, width=100, height=100) + + self.layout_manager.register_widget("left", w1) + self.layout_manager.register_widget("left", w2) + self.layout_manager.register_widget("left", w3) + + self.layout_manager.update_layout(self.content_rect, is_rhd=False) + + # w1: y = 75 + self.assertEqual(w1.rect.y, 75) + # w2: is invisible, should not be set (or rather, is skipped in stacking) + # w3: should stack directly below w1: y = 75 + 100 + 15 = 190 + self.assertEqual(w3.rect.y, 190) + + def test_dynamic_repositioning_on_rect_change(self): + # Register a widget + w1 = DummyLayoutWidget("w1", priority=1, width=100, height=100) + self.layout_manager.register_widget("right", w1) + + # Initial layout + self.layout_manager.update_layout(self.content_rect, is_rhd=False) + # center_x = 30 + 2100 - 146 = 1984. x = 1984 - 50 = 1934 + initial_x = w1.rect.x + + # New layout with different rect + new_rect = rl.Rectangle(0, 0, 1920, 1080) + self.layout_manager.update_layout(new_rect, is_rhd=False) + # center_x = 0 + 1920 - 146 = 1774. x = 1774 - 50 = 1724 + new_x = w1.rect.x + + self.assertNotEqual(initial_x, new_x) + self.assertEqual(initial_x, 1934) + self.assertEqual(new_x, 1724) + + def test_right_zone_vertical_stacking(self): + # Right zone widgets stack vertically + # X anchor is self.content_rect.x + self.content_rect.width - 146 + w1 = DummyLayoutWidget("pedal_icons", priority=1, width=100, height=200) + w2 = DummyLayoutWidget("other", priority=2, width=100, height=100) + + self.layout_manager.register_widget("right", w1) + self.layout_manager.register_widget("right", w2) + + self.layout_manager.update_layout(self.content_rect, is_rhd=False) + + # center_x = 30 + 2100 - 146 = 1984 + # w1 x = 1984 - 50 = 1934 + # w1 y = 30 + 45 = 75 + self.assertEqual(w1.rect.x, 1934) + self.assertEqual(w1.rect.y, 75) + + # w2 x = 1934 + # w2 y = 75 + 200 + 15 = 290 + self.assertEqual(w2.rect.x, 1934) + self.assertEqual(w2.rect.y, 290) + + + +if __name__ == "__main__": + unittest.main() +