import pyray as rl from dataclasses import dataclass from iqpilot.common.constants import CV from iqpilot.selfdrive.ui.onroad.exp_button import ExpButton from iqpilot.selfdrive.ui.ui_state import ui_state, UIStatus from iqpilot.system.ui.lib.application import gui_app, FontWeight from iqpilot.system.ui.lib.multilang import tr from iqpilot.system.ui.lib.text_measure import measure_text_cached from iqpilot.system.ui.widgets import Widget # Constants SET_SPEED_NA = 255 KM_TO_MILE = 0.621371 CRUISE_DISABLED_CHAR = '–' @dataclass(frozen=True) class UIConfig: header_height: int = 300 border_size: int = 30 button_size: int = 192 set_speed_width_metric: int = 186 set_speed_width_imperial: int = 174 set_speed_height: int = 228 wheel_icon_size: int = 144 @dataclass(frozen=True) class FontSizes: current_speed: int = 176 speed_unit: int = 66 max_speed: int = 28 set_speed: int = 74 limit_speed: int = 64 limit_offset: int = 30 limit_unit: int = 22 limit_label: int = 24 @dataclass(frozen=True) class Colors: WHITE = rl.WHITE DISENGAGED = rl.Color(145, 155, 149, 255) OVERRIDE = rl.Color(145, 155, 149, 255) # Added ENGAGED = rl.Color(0x0C, 0x94, 0x96, 0xFF) LIMIT_ENGAGED = rl.Color(0x27, 0xF5, 0xD3, 0xFF) DISENGAGED_BG = rl.Color(0, 0, 0, 153) OVERRIDE_BG = rl.Color(145, 155, 149, 204) ENGAGED_BG = rl.Color(128, 216, 166, 204) GREY = rl.Color(166, 166, 166, 255) DARK_GREY = rl.Color(114, 114, 114, 255) BLACK_TRANSLUCENT = rl.Color(0, 0, 0, 166) WHITE_TRANSLUCENT = rl.Color(255, 255, 255, 200) BORDER_TRANSLUCENT = rl.Color(255, 255, 255, 75) HEADER_GRADIENT_START = rl.Color(0, 0, 0, 114) HEADER_GRADIENT_END = rl.BLANK UI_CONFIG = UIConfig() FONT_SIZES = FontSizes() COLORS = Colors() 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.speed: float = 0.0 self.v_ego_cluster_seen: bool = False self.limit_speed_text: str = "---" self.limit_offset_text: str = "" self.limit_available: bool = False self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) self._exp_button: ExpButton = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size) 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 self.set_speed = ( controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster ) self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA self.is_cruise_available = self.set_speed != -1 if self.is_cruise_set and not ui_state.is_metric: self.set_speed *= KM_TO_MILE 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) def _render(self, rect: rl.Rectangle) -> None: """Render HUD elements to the screen.""" # Draw the header background rl.draw_rectangle_gradient_v( int(rect.x), int(rect.y), int(rect.width), UI_CONFIG.header_height, COLORS.HEADER_GRADIENT_START, COLORS.HEADER_GRADIENT_END, ) if self.is_cruise_available: self._draw_set_speed(rect) self._draw_current_speed(rect) 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)) def user_interacting(self) -> bool: return self._exp_button.is_pressed def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the compact stacked LIMIT / MAX speed indicator box.""" set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial x = rect.x + 60 + (UI_CONFIG.set_speed_width_imperial - set_speed_width) // 2 y = rect.y + 45 set_speed_rect = rl.Rectangle(x, y, set_speed_width, UI_CONFIG.set_speed_height) rl.draw_rectangle_rounded(set_speed_rect, 0.35, 10, COLORS.BLACK_TRANSLUCENT) rl.draw_rectangle_rounded_lines_ex(set_speed_rect, 0.35, 10, 6, COLORS.BORDER_TRANSLUCENT) split_y = y + 112 rl.draw_line_ex( rl.Vector2(x + 18, split_y), rl.Vector2(x + set_speed_width - 18, split_y), 3, COLORS.BORDER_TRANSLUCENT, ) max_color = COLORS.DARK_GREY set_speed_color = COLORS.WHITE limit_value_color = COLORS.WHITE if self.limit_available else COLORS.DARK_GREY if self.is_cruise_set: if ui_state.status == UIStatus.ENGAGED: max_color = COLORS.ENGAGED if self.limit_available: limit_value_color = COLORS.LIMIT_ENGAGED elif ui_state.status == UIStatus.DISENGAGED: max_color = COLORS.DISENGAGED elif ui_state.status == UIStatus.OVERRIDE: max_color = COLORS.OVERRIDE limit_value_text = self.limit_speed_text if len(limit_value_text) <= 2: limit_value_size = FONT_SIZES.limit_speed elif len(limit_value_text) == 3: limit_value_size = 56 else: limit_value_size = 48 limit_value_width = measure_text_cached(self._font_bold, limit_value_text, limit_value_size).x limit_offset_text = self.limit_offset_text if self.limit_available else "" limit_offset_width = 0.0 if limit_offset_text: limit_offset_width = measure_text_cached(self._font_semi_bold, limit_offset_text, FONT_SIZES.limit_offset).x + 8 limit_value_x = x + (set_speed_width - limit_value_width - limit_offset_width) / 2 rl.draw_text_ex( self._font_bold, limit_value_text, rl.Vector2(limit_value_x, y + 14), limit_value_size, 0, limit_value_color, ) if limit_offset_text: rl.draw_text_ex( self._font_semi_bold, limit_offset_text, rl.Vector2(limit_value_x + limit_value_width + 8, y + 22), FONT_SIZES.limit_offset, 0, COLORS.WHITE_TRANSLUCENT, ) if self.limit_available: limit_unit_text = tr("LIMIT") limit_unit_width = measure_text_cached(self._font_medium, limit_unit_text, FONT_SIZES.limit_unit).x rl.draw_text_ex( self._font_medium, limit_unit_text, rl.Vector2(x + (set_speed_width - limit_unit_width) / 2, y + 78), FONT_SIZES.limit_unit, 0, COLORS.WHITE_TRANSLUCENT, ) else: limit_label_text = tr("LIMIT") limit_label_width = measure_text_cached(self._font_semi_bold, limit_label_text, FONT_SIZES.limit_label).x rl.draw_text_ex( self._font_semi_bold, limit_label_text, rl.Vector2(x + (set_speed_width - limit_label_width) / 2, y + 74), FONT_SIZES.limit_label, 0, COLORS.GREY, ) 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(x + (set_speed_width - max_text_width) / 2, y + 118), FONT_SIZES.max_speed, 0, max_color, ) set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(self.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(x + (set_speed_width - speed_text_width) / 2, y + 136), FONT_SIZES.set_speed, 0, set_speed_color, ) 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)