Big UI : Widget Layout Manager : RIP?

This commit is contained in:
firestarsdog
2026-07-19 17:57:33 -04:00
parent c928e27033
commit 8376ba7c97
17 changed files with 812 additions and 260 deletions
+3 -1
View File
@@ -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
+3
View File
@@ -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
+6 -2
View File
@@ -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
+94 -138
View File
@@ -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()