Files
StarPilot/selfdrive/ui/mici/onroad/hud_renderer.py
T
firestar5683 518b0f0345 Barfday Push
2026-08-07 16:00:13 -05:00

719 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
import pyray as rl
from dataclasses import dataclass
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.onroad.starpilot.torque_bar import TorqueBar
from openpilot.selfdrive.ui.mici.onroad.speed_limit_utils import resolve_display_speed_limit_ms
from openpilot.selfdrive.ui.onroad.starpilot.navigation_card import NavigationCardRenderer
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.system.ui.widgets import Widget
from openpilot.common.filter_simple import FirstOrderFilter
from cereal import log
EventName = log.OnroadEvent.EventName
# Constants
SET_SPEED_NA = 255
KM_TO_MILE = 0.621371
CRUISE_DISABLED_CHAR = ''
SET_SPEED_PERSISTENCE = 2.5 # seconds
SPEED_LIMIT_PROMPT_CARD_WIDTH = 500
SPEED_LIMIT_PROMPT_CARD_HEIGHT = 208
SPEED_LIMIT_PROMPT_BUTTON_SIZE = 112
SPEED_LIMIT_PROMPT_BUTTON_GAP = 28
SPEED_LIMIT_PROMPT_CARD_PADDING = 34
SPEED_LIMIT_PROMPT_US_SIGN_WIDTH = 132
SPEED_LIMIT_PROMPT_US_SIGN_HEIGHT = 150
SPEED_LIMIT_PROMPT_EU_SIGN_SIZE = 148
SPEED_LIMIT_PROMPT_CENTER_OFFSET_X = -26
VISION_SPEED_LIMIT_PULSE_SECONDS = 1.0
VISION_SPEED_LIMIT_PULSE_COLOR = rl.Color(188, 132, 255, 255)
@dataclass(frozen=True)
class FontSizes:
current_speed: int = 176
speed_unit: int = 66
max_speed: int = 36
set_speed: int = 112
@dataclass(frozen=True)
class Colors:
WHITE = rl.WHITE
WHITE_TRANSLUCENT = rl.Color(255, 255, 255, 200)
FONT_SIZES = FontSizes()
COLORS = Colors()
class TurnIntent(Widget):
FADE_IN_ANGLE = 30 # degrees
def __init__(self):
super().__init__()
self._pre = False
self._turn_intent_direction: int = 0
self._turn_intent_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._turn_intent_rotation_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._txt_turn_intent_left: rl.Texture = gui_app.texture('icons_mici/turn_intent_left.png', 50, 19)
self._txt_turn_intent_right: rl.Texture = gui_app.texture('icons_mici/turn_intent_right.png', 50, 19)
def _render(self, _):
if self._turn_intent_alpha_filter.x > 1e-2:
turn_intent_texture = self._txt_turn_intent_right if self._turn_intent_direction == 1 else self._txt_turn_intent_left
src_rect = rl.Rectangle(0, 0, turn_intent_texture.width, turn_intent_texture.height)
dest_rect = rl.Rectangle(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2,
turn_intent_texture.width, turn_intent_texture.height)
origin = (turn_intent_texture.width / 2, self._rect.height / 2)
color = rl.Color(255, 255, 255, int(255 * self._turn_intent_alpha_filter.x))
rl.draw_texture_pro(turn_intent_texture, src_rect, dest_rect, origin, self._turn_intent_rotation_filter.x, color)
def _update_state(self) -> None:
sm = ui_state.sm
left = any(e.name == EventName.preLaneChangeLeft for e in sm['onroadEvents'])
right = any(e.name == EventName.preLaneChangeRight for e in sm['onroadEvents'])
if left or right:
# pre lane change
if not self._pre:
self._turn_intent_rotation_filter.x = self.FADE_IN_ANGLE if left else -self.FADE_IN_ANGLE
self._pre = True
self._turn_intent_direction = -1 if left else 1
self._turn_intent_alpha_filter.update(1)
self._turn_intent_rotation_filter.update(0)
elif any(e.name == EventName.laneChange for e in sm['onroadEvents']):
# fade out and rotate away
self._pre = False
self._turn_intent_alpha_filter.update(0)
if self._turn_intent_direction == 0:
# unknown. missed pre frame?
self._turn_intent_rotation_filter.update(0)
else:
self._turn_intent_rotation_filter.update(self._turn_intent_direction * self.FADE_IN_ANGLE)
else:
# didn't complete lane change, just hide
self._pre = False
self._turn_intent_direction = 0
self._turn_intent_alpha_filter.update(0)
self._turn_intent_rotation_filter.update(0)
class HudRenderer(Widget):
def __init__(self):
super().__init__()
"""Initialize the HUD renderer."""
self.is_cruise_set: bool = False
self.is_cruise_available: bool = True
self.set_speed: float = SET_SPEED_NA
self._set_speed_changed_time: float = 0
self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self._engaged: bool = False
self._small_model_engaged: bool = False
self._egpu_fade_time: float = 0.0
self._show_speed_limit: bool = False
self._speed_limit: float = 0.0
self._speed_limit_offset: float = 0.0
self._show_speed_limit_offset: bool = False
self._speed_limit_overridden: bool = False
self._pending_speed_limit: float = 0.0
self._vision_speed_limit_active: bool = False
self._last_vision_speed_limit: float = 0.0
self._vision_speed_limit_pulse_start: float = -VISION_SPEED_LIMIT_PULSE_SECONDS
self._prompt_visible: bool = False
self._prompt_card_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._prompt_sign_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._prompt_deny_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._prompt_accept_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._can_draw_top_icons = True
self._show_wheel_critical = False
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
self._font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
self._turn_intent = TurnIntent()
self._torque_bar = TorqueBar()
self._navigation_card = NavigationCardRenderer("mici")
self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50)
self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50)
self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 44, 44)
self._txt_egpu_loading: rl.Texture = gui_app.texture('icons_mici/egpu_loading.png', 60, 44)
self._txt_egpu_green: rl.Texture = gui_app.texture('icons_mici/egpu_green.png', 60, 44)
self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 60, 44)
self._txt_egpu_crossed: rl.Texture = gui_app.texture('icons_mici/egpu_crossed.png', 60, 52)
self._egpu_icon: rl.Texture | None = None
self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._egpu_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
def set_wheel_critical_icon(self, critical: bool):
"""Set the wheel icon to critical or normal state."""
self._show_wheel_critical = critical
def set_can_draw_top_icons(self, can_draw_top_icons: bool):
"""Set whether to draw the top part of the HUD."""
self._can_draw_top_icons = can_draw_top_icons
def drawing_top_icons(self) -> bool:
# whether we're drawing any top icons currently
return bool(self._set_speed_alpha_filter.x > 1e-2)
def _update_state(self) -> None:
"""Update HUD state based on car state and controls state."""
sm = ui_state.sm
if sm.recv_frame["carState"] < ui_state.started_frame:
self.is_cruise_set = False
self.set_speed = SET_SPEED_NA
self.speed = 0.0
return
controls_state = sm['controlsState']
car_state = sm['carState']
v_cruise_cluster = car_state.vCruiseCluster
set_speed = (
controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
)
engaged = sm['selfdriveState'].enabled
if (engaged and not self._engaged and not ui_state.usbgpu_loading and ui_state.usbgpu_active is not True and
sm.recv_frame['modelV2'] > ui_state.started_frame):
self._small_model_engaged = True
if engaged and not self._engaged:
self._egpu_fade_time = rl.get_time()
if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged):
self._set_speed_changed_time = rl.get_time()
self._engaged = engaged
self.set_speed = set_speed
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
self.is_cruise_available = self.set_speed != -1
v_ego_cluster = car_state.vEgoCluster
self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
self.speed = max(0.0, v_ego * speed_conversion)
if sm.recv_frame["starpilotPlan"] >= ui_state.started_frame:
starpilot_plan = sm["starpilotPlan"]
self._show_speed_limit = ui_state.params.get_bool("ShowSpeedLimits")
if self._show_speed_limit:
dashboard_speed_limit = sm["starpilotCarState"].dashboardSpeedLimit if sm.valid.get("starpilotCarState", False) else 0.0
vision_speed_limit = ui_state.params_memory.get_float("VisionSpeedLimit") if ui_state.params.get_bool("VisionSpeedLimitDetection") else 0.0
self._show_speed_limit_offset = ui_state.params.get_bool("ShowSLCOffset")
primary_priority = ui_state.params.get("SLCPriority1", encoding='utf-8') or "Map Data"
secondary_priority = ui_state.params.get("SLCPriority2", encoding='utf-8') or "None"
source_limits = {
"Dashboard": dashboard_speed_limit,
"Map Data": starpilot_plan.slcMapSpeedLimit,
"Vision": vision_speed_limit,
"Mapbox": starpilot_plan.slcMapboxSpeedLimit if ui_state.params.get_bool("SLCMapboxFiller") else 0.0,
}
resolved_speed_limit = resolve_display_speed_limit_ms(
slc_speed_limit=starpilot_plan.slcSpeedLimit,
speed_limit_source=starpilot_plan.slcSpeedLimitSource,
source_limits=source_limits,
primary_priority=primary_priority,
secondary_priority=secondary_priority,
)
vision_speed_limit_active = starpilot_plan.slcSpeedLimitSource == "Vision" and resolved_speed_limit > 0.0
vision_speed_limit_changed = abs(resolved_speed_limit - self._last_vision_speed_limit) >= 0.1
if vision_speed_limit_active and (not self._vision_speed_limit_active or vision_speed_limit_changed):
self._vision_speed_limit_pulse_start = rl.get_time()
self._vision_speed_limit_active = vision_speed_limit_active
self._last_vision_speed_limit = resolved_speed_limit if vision_speed_limit_active else 0.0
display_speed_limit = starpilot_plan.slcOverriddenSpeed if starpilot_plan.slcOverriddenSpeed > 0 else resolved_speed_limit
self._speed_limit = max(0.0, display_speed_limit * speed_conversion)
self._speed_limit_offset = starpilot_plan.slcSpeedLimitOffset * speed_conversion
self._speed_limit_overridden = bool(starpilot_plan.slcOverriddenSpeed > 0 and starpilot_plan.slcSpeedLimit > 0)
if self._speed_limit > 0 and not self._speed_limit_overridden and not self._show_speed_limit_offset:
self._speed_limit += self._speed_limit_offset
self._pending_speed_limit = max(0.0, starpilot_plan.unconfirmedSlcSpeedLimit * speed_conversion)
else:
self._speed_limit = 0.0
self._speed_limit_offset = 0.0
self._show_speed_limit_offset = False
self._speed_limit_overridden = False
self._pending_speed_limit = 0.0
self._vision_speed_limit_active = False
self._last_vision_speed_limit = 0.0
self._prompt_visible = self._pending_speed_limit > 0
else:
self._show_speed_limit = False
self._speed_limit = 0.0
self._speed_limit_offset = 0.0
self._show_speed_limit_offset = False
self._speed_limit_overridden = False
self._pending_speed_limit = 0.0
self._vision_speed_limit_active = False
self._last_vision_speed_limit = 0.0
self._prompt_visible = False
def prepare(self, rect: rl.Rectangle) -> None:
"""Update HUD state once before drawing background/foreground passes."""
self.set_rect(rect)
self._update_state()
self._update_prompt_layout(rect)
def render_background(self) -> None:
"""Draw HUD elements that should sit behind alerts."""
self._draw_speed_limit(self._rect)
self._navigation_card.render(self._rect)
def render_foreground(self) -> None:
"""Draw HUD elements that should sit above alerts."""
if ui_state.params.get_bool("EnableTorqueBarWidget", default=True):
self._torque_bar.render(self._rect)
if self.is_cruise_set:
self._draw_set_speed(self._rect)
if ui_state.usbgpu and ui_state.usbgpu_compiled:
self._draw_model_source(self._rect)
self._draw_steering_wheel(self._rect)
self._draw_speed_limit_prompt(self._rect)
def user_interacting(self) -> bool:
return self._navigation_card.is_pressed
def _render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen."""
self.prepare(rect)
self.render_background()
self.render_foreground()
def _draw_model_source(self, rect: rl.Rectangle) -> None:
"""Show which driving model is supplying on-road predictions."""
if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame:
return
model_seen = ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame
model_alive = ui_state.sm.alive['modelV2'] if model_seen else True
big_failed = (
ui_state.usbgpu_active is False or
not ui_state.usbgpu or
(ui_state.usbgpu_active is True and model_seen and not model_alive)
)
self._small_model_engaged &= big_failed
loading = ui_state.usbgpu_loading
if loading:
pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0)
icon = self._txt_egpu_loading
opacity = 0.35 + 0.65 * pulse
elif self._small_model_engaged:
icon = self._txt_egpu_crossed
opacity = 0.65
elif big_failed:
icon = self._txt_egpu_orange
opacity = 1.0
else:
icon = self._txt_egpu_green
opacity = 1.0
if icon is not self._egpu_icon:
self._egpu_fade_time = rl.get_time()
self._egpu_icon = icon
alpha = self._egpu_alpha_filter.update(
loading or (0 < rl.get_time() - self._egpu_fade_time < SET_SPEED_PERSISTENCE and self._engaged)
)
if alpha < 1e-2:
return
pos = rl.Vector2(
rect.x + rect.width - 10 - icon.width,
rect.y + rect.height - 14 - (self._txt_wheel.height + icon.height) / 2,
)
rl.draw_texture_ex(icon, pos, 0.0, 1.0, rl.Color(255, 255, 255, int(255 * opacity * alpha)))
def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active
if self._show_wheel_critical:
self._wheel_alpha_filter.update(255)
self._wheel_y_filter.update(0)
else:
if not lateral_ui_active and ui_state.status == UIStatus.DISENGAGED:
self._wheel_alpha_filter.update(0)
self._wheel_y_filter.update(wheel_txt.height / 2)
else:
self._wheel_alpha_filter.update(255 * 0.9)
self._wheel_y_filter.update(0)
# pos
pos_x = int(rect.x + 21 + wheel_txt.width / 2)
pos_y = int(rect.y + rect.height - 14 - wheel_txt.height / 2 + self._wheel_y_filter.x)
rotation = -ui_state.sm['carState'].steeringAngleDeg
turn_intent_margin = 25
self._turn_intent.render(rl.Rectangle(
pos_x - wheel_txt.width / 2 - turn_intent_margin,
pos_y - wheel_txt.height / 2 - turn_intent_margin,
wheel_txt.width + turn_intent_margin * 2,
wheel_txt.height + turn_intent_margin * 2,
))
src_rect = rl.Rectangle(0, 0, wheel_txt.width, wheel_txt.height)
dest_rect = rl.Rectangle(pos_x, pos_y, wheel_txt.width, wheel_txt.height)
origin = (wheel_txt.width / 2, wheel_txt.height / 2)
# color and draw
color = rl.Color(255, 255, 255, int(self._wheel_alpha_filter.x))
rl.draw_texture_pro(wheel_txt, src_rect, dest_rect, origin, rotation, color)
if self._show_wheel_critical:
# Draw exclamation point icon
EXCLAMATION_POINT_SPACING = 10
exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING
exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2
rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE)
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the MAX speed indicator box."""
alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and
self._can_draw_top_icons and self._engaged)
if alpha < 1e-2:
return
x = rect.x
y = rect.y
# draw drop shadow
circle_radius = 162 // 2
rl.draw_circle_gradient(int(x + circle_radius), int(y + circle_radius), circle_radius,
rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.BLANK)
set_speed_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
set_speed = self.set_speed
if self.is_cruise_set and not ui_state.is_metric:
set_speed *= KM_TO_MILE
set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed))
rl.draw_text_ex(
self._font_display,
set_speed_text,
rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4),
FONT_SIZES.set_speed,
0,
set_speed_color,
)
max_text = tr("MAX")
rl.draw_text_ex(
self._font_semi_bold,
max_text,
rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4),
FONT_SIZES.max_speed,
0,
max_color,
)
def _draw_us_speed_limit_sign(
self,
sign_rect: rl.Rectangle,
speed_text: str,
sign_alpha: int,
*,
border_thickness: float,
header_font_size: int,
header_gap: int,
speed_font_size: int,
header_top: float,
speed_top: float,
footer_text: str = "",
footer_font_size: int = 0,
footer_top: float = 0.0,
border_color = None,
text_color = None,
) -> None:
border_color = border_color or rl.Color(255, 255, 255, sign_alpha)
text_color = text_color or rl.Color(255, 255, 255, sign_alpha)
inner_border_rect = rl.Rectangle(
sign_rect.x + 8,
sign_rect.y + 8,
sign_rect.width - 16,
sign_rect.height - 16,
)
rl.draw_rectangle_rounded_lines_ex(inner_border_rect, 0.14, 16, max(border_thickness - 2, 1), border_color)
speed_label = tr("SPEED")
limit_label = tr("LIMIT")
speed_label_size = measure_text_cached(self._font_semi_bold, speed_label, header_font_size)
limit_label_size = measure_text_cached(self._font_semi_bold, limit_label, header_font_size)
rl.draw_text_ex(
self._font_semi_bold,
speed_label,
rl.Vector2(sign_rect.x + sign_rect.width / 2 - speed_label_size.x / 2, sign_rect.y + header_top),
header_font_size,
0,
text_color,
)
rl.draw_text_ex(
self._font_semi_bold,
limit_label,
rl.Vector2(sign_rect.x + sign_rect.width / 2 - limit_label_size.x / 2, sign_rect.y + header_top + header_gap),
header_font_size,
0,
text_color,
)
speed_text_size = measure_text_cached(self._font_bold, speed_text, speed_font_size)
speed_text_pos = rl.Vector2(sign_rect.x + sign_rect.width / 2 - speed_text_size.x / 2, sign_rect.y + speed_top)
rl.draw_text_ex(self._font_bold, speed_text, speed_text_pos, speed_font_size, 0, text_color)
if footer_text and footer_font_size > 0:
footer_size = measure_text_cached(self._font_semi_bold, footer_text, footer_font_size)
footer_pos = rl.Vector2(sign_rect.x + sign_rect.width / 2 - footer_size.x / 2, sign_rect.y + footer_top)
rl.draw_text_ex(self._font_semi_bold, footer_text, footer_pos, footer_font_size, 0, text_color)
def _speed_limit_pulse_color(self, base_color, alpha: int) -> rl.Color:
base = rl.Color(base_color.r, base_color.g, base_color.b, alpha)
elapsed = rl.get_time() - self._vision_speed_limit_pulse_start
if elapsed < 0.0 or elapsed >= VISION_SPEED_LIMIT_PULSE_SECONDS:
return base
progress = elapsed / VISION_SPEED_LIMIT_PULSE_SECONDS
pulse = math.sin(math.pi * progress)
return rl.Color(
round(base.r + (VISION_SPEED_LIMIT_PULSE_COLOR.r - base.r) * pulse),
round(base.g + (VISION_SPEED_LIMIT_PULSE_COLOR.g - base.g) * pulse),
round(base.b + (VISION_SPEED_LIMIT_PULSE_COLOR.b - base.b) * pulse),
alpha,
)
def _draw_speed_limit(self, rect: rl.Rectangle) -> None:
if not self._show_speed_limit:
return
display_speed = self._speed_limit if self._speed_limit > 0 else self._pending_speed_limit
if display_speed <= 0:
return
sign_alpha = 72 if self._speed_limit_overridden and self._pending_speed_limit <= 0 else 255
use_vienna_speed_limit = ui_state.params.get_bool("UseVienna")
speed_text = str(round(display_speed))
offset_text = ""
if self._show_speed_limit_offset and not self._speed_limit_overridden:
rounded_offset = round(self._speed_limit_offset)
offset_text = "" if rounded_offset == 0 else f"{rounded_offset:+d}"
sign_width = 118 if use_vienna_speed_limit else 116
sign_height = 118 if use_vienna_speed_limit else (142 if offset_text else 132)
base_x = rect.x + rect.width - sign_width - 28
sign_x = base_x
sign_y = rect.y + (28 if use_vienna_speed_limit else 20)
widget_color = self._speed_limit_pulse_color(rl.Color(255, 255, 255, 255), sign_alpha)
if use_vienna_speed_limit:
center_x = sign_x + sign_width / 2
center_y = sign_y + sign_height / 2
radius = sign_width / 2
ring_color = self._speed_limit_pulse_color(rl.Color(201, 34, 49, 255), sign_alpha)
text_color = self._speed_limit_pulse_color(rl.Color(0, 0, 0, 255), sign_alpha)
rl.draw_circle(int(center_x), int(center_y), radius, rl.Color(255, 255, 255, sign_alpha))
rl.draw_ring(
rl.Vector2(center_x, center_y),
radius - 12,
radius,
0,
360,
64,
ring_color,
)
font_size = 58 if len(speed_text) <= 2 else 48
text_size = measure_text_cached(self._font_bold, speed_text, font_size)
text_pos = rl.Vector2(center_x - text_size.x / 2, center_y - text_size.y / 2 + (-14 if offset_text else 4))
rl.draw_text_ex(self._font_bold, speed_text, text_pos, font_size, 0, text_color)
if offset_text:
offset_font_size = 34
offset_size = measure_text_cached(self._font_semi_bold, offset_text, offset_font_size)
offset_pos = rl.Vector2(center_x - offset_size.x / 2, sign_y + sign_height - 42)
rl.draw_text_ex(self._font_semi_bold, offset_text, offset_pos, offset_font_size, 0, text_color)
else:
sign_rect = rl.Rectangle(sign_x, sign_y, sign_width, sign_height)
self._draw_us_speed_limit_sign(
sign_rect,
speed_text,
sign_alpha,
border_thickness=4,
header_font_size=20,
header_gap=18,
speed_font_size=44 if offset_text else (50 if len(speed_text) <= 2 else 42),
header_top=18,
speed_top=58 if offset_text else 66,
footer_text=offset_text,
footer_font_size=28 if offset_text else 0,
footer_top=100,
border_color=widget_color,
text_color=widget_color,
)
def _update_prompt_layout(self, rect: rl.Rectangle) -> None:
if not self._prompt_visible:
self._prompt_card_rect = rl.Rectangle(0, 0, 0, 0)
self._prompt_sign_rect = rl.Rectangle(0, 0, 0, 0)
self._prompt_deny_rect = rl.Rectangle(0, 0, 0, 0)
self._prompt_accept_rect = rl.Rectangle(0, 0, 0, 0)
return
use_vienna_speed_limit = ui_state.params.get_bool("UseVienna")
sign_width = SPEED_LIMIT_PROMPT_EU_SIGN_SIZE if use_vienna_speed_limit else SPEED_LIMIT_PROMPT_US_SIGN_WIDTH
sign_height = SPEED_LIMIT_PROMPT_EU_SIGN_SIZE if use_vienna_speed_limit else SPEED_LIMIT_PROMPT_US_SIGN_HEIGHT
button_size = SPEED_LIMIT_PROMPT_BUTTON_SIZE
card_margin_x = 8
card_margin_y = 8
card_x = rect.x + card_margin_x + SPEED_LIMIT_PROMPT_CENTER_OFFSET_X
card_y = rect.y + card_margin_y
card_width = max(
SPEED_LIMIT_PROMPT_CARD_WIDTH,
rect.width - card_margin_x * 2,
)
card_height = max(
SPEED_LIMIT_PROMPT_CARD_HEIGHT,
rect.height - card_margin_y * 2,
)
card_rect = rl.Rectangle(card_x, card_y, card_width, card_height)
controls_center_y = card_y + card_height * 0.72
controls_y = controls_center_y - button_size / 2
deny_x = card_x + SPEED_LIMIT_PROMPT_CARD_PADDING
sign_x = card_x + (card_width - sign_width) / 2
sign_y = controls_center_y - sign_height / 2
accept_x = card_x + card_width - SPEED_LIMIT_PROMPT_CARD_PADDING - button_size
self._prompt_card_rect = card_rect
self._prompt_sign_rect = rl.Rectangle(sign_x, sign_y, sign_width, sign_height)
self._prompt_deny_rect = rl.Rectangle(deny_x, controls_y, button_size, button_size)
self._prompt_accept_rect = rl.Rectangle(accept_x, controls_y, button_size, button_size)
def _draw_prompt_button(self, rect: rl.Rectangle, symbol: str, fill: rl.Color, outline: rl.Color, pressed: bool) -> None:
scale = 0.95 if pressed else 1.0
width = rect.width * scale
height = rect.height * scale
x = rect.x + (rect.width - width) / 2
y = rect.y + (rect.height - height) / 2
button_rect = rl.Rectangle(x, y, width, height)
center = rl.Vector2(button_rect.x + button_rect.width / 2, button_rect.y + button_rect.height / 2)
radius = min(button_rect.width, button_rect.height) / 2
rl.draw_circle_gradient(int(center.x), int(center.y), radius, rl.Color(0, 0, 0, 90), rl.BLANK)
rl.draw_circle(int(center.x), int(center.y), radius, fill)
rl.draw_ring(center, radius - 6, radius, 0, 360, 48, outline)
symbol_size = 88 if symbol == "+" else 98
symbol_text = tr(symbol)
symbol_measure = measure_text_cached(self._font_display, symbol_text, symbol_size)
symbol_pos = rl.Vector2(center.x - symbol_measure.x / 2, center.y - symbol_measure.y / 2 - (6 if symbol == "-" else 0))
rl.draw_text_ex(self._font_display, symbol_text, symbol_pos, symbol_size, 0, rl.WHITE)
def _draw_speed_limit_prompt(self, rect: rl.Rectangle) -> None:
if not self._prompt_visible:
return
accent_color = rl.Color(255, 115, 0, 255)
card_rect = self._prompt_card_rect
rl.draw_rectangle_rounded(card_rect, 0.14, 18, rl.Color(10, 10, 10, 210))
rl.draw_rectangle_rounded_lines_ex(card_rect, 0.14, 18, 4, accent_color)
title_text = tr("NEW SPEED LIMIT")
title_size = measure_text_cached(self._font_semi_bold, title_text, 28)
title_pos = rl.Vector2(card_rect.x + card_rect.width / 2 - title_size.x / 2, card_rect.y + 18)
rl.draw_text_ex(self._font_semi_bold, title_text, title_pos, 28, 0, rl.Color(255, 255, 255, 235))
use_vienna_speed_limit = ui_state.params.get_bool("UseVienna")
speed_text = str(round(self._pending_speed_limit))
sign_rect = self._prompt_sign_rect
if use_vienna_speed_limit:
center_x = sign_rect.x + sign_rect.width / 2
center_y = sign_rect.y + sign_rect.height / 2
radius = sign_rect.width / 2
rl.draw_circle(int(center_x), int(center_y), radius, rl.WHITE)
rl.draw_ring(
rl.Vector2(center_x, center_y),
radius - 14,
radius,
0,
360,
64,
rl.Color(201, 34, 49, 255),
)
font_size = 78 if len(speed_text) <= 2 else 66
text_size = measure_text_cached(self._font_bold, speed_text, font_size)
text_pos = rl.Vector2(center_x - text_size.x / 2, center_y - text_size.y / 2 + 4)
rl.draw_text_ex(self._font_bold, speed_text, text_pos, font_size, 0, rl.BLACK)
else:
self._draw_us_speed_limit_sign(
sign_rect,
speed_text,
255,
border_thickness=5,
header_font_size=24,
header_gap=20,
speed_font_size=72 if len(speed_text) <= 2 else 60,
header_top=14,
speed_top=66,
)
self._draw_prompt_button(
self._prompt_deny_rect,
"-",
rl.Color(104, 20, 20, 235),
rl.Color(255, 78, 78, 255),
False,
)
self._draw_prompt_button(
self._prompt_accept_rect,
"+",
rl.Color(12, 110, 66, 235),
rl.Color(78, 255, 173, 255),
False,
)
hint_text = tr("SET/+ TO CONFIRM RES/- TO DENY")
hint_size = measure_text_cached(self._font_medium, hint_text, 24)
hint_pos = rl.Vector2(card_rect.x + card_rect.width / 2 - hint_size.x / 2, card_rect.y + 60)
rl.draw_text_ex(self._font_medium, hint_text, hint_pos, 24, 0, rl.Color(255, 255, 255, 180))
def _draw_current_speed(self, rect: rl.Rectangle) -> None:
"""Draw the current vehicle speed and unit."""
speed_text = str(round(self.speed))
speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed)
speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2)
rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.WHITE)
unit_text = tr("km/h") if ui_state.is_metric else tr("mph")
unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit)
unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2)
rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.WHITE_TRANSLUCENT)