mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-21 01:02:48 +08:00
Big UI : Widget Layout Manager : RIP?
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user