diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 98dbcfe96..a601c1318 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -9,6 +9,9 @@ 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 draw_text_with_shadow, measure_text_cached from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import ( + CONTROL_WIDTH, SET_SPEED_HEIGHT, WIDGET_ANCHOR_OFFSET, draw_control_card, +) # Constants SET_SPEED_NA = 255 @@ -21,9 +24,9 @@ class UIConfig: header_height: int = 300 border_size: int = 30 button_size: int = 192 - set_speed_width_metric: int = 200 - set_speed_width_imperial: int = 172 - set_speed_height: int = 204 + set_speed_width_metric: int = CONTROL_WIDTH + set_speed_width_imperial: int = CONTROL_WIDTH + set_speed_height: int = SET_SPEED_HEIGHT wheel_icon_size: int = 144 @@ -145,12 +148,11 @@ class HudRenderer(Widget): def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the 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 + x = rect.x + WIDGET_ANCHOR_OFFSET - 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) + draw_control_card(set_speed_rect) max_color = COLORS.GREY set_speed_color = COLORS.DARK_GREY diff --git a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py index ce9703b63..eb61348d6 100644 --- a/selfdrive/ui/onroad/starpilot/slc_speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/slc_speed_limit.py @@ -7,19 +7,15 @@ 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 from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import ( + CONTROL_BG, CONTROL_BORDER_WIDTH, SLC_HEIGHT, + draw_control_card, roundness_for, +) + +_WHITE = rl.Color(255, 255, 255, 255) # ── Constants ───────────────────────────────────────────────────────── -# Set speed rect layout (from hud_renderer.py UI_CONFIG) -SET_SPEED_WIDTH_IMP = 172 -SET_SPEED_WIDTH_MET = 200 - -# US MUTCD sign -US_SIGN_HEIGHT = 186 -US_INNER_RADIUS = 16 -US_BORDER_WIDTH = 6 -US_INSET = 9 - # EU Vienna sign EU_SIGN_SIZE = 176 EU_SIGN_WIDTH = 176 @@ -30,24 +26,21 @@ PENDING_BLINK_MS = 500 # Source display metadata: title, abbreviation, raw-value key (display order). SOURCE_DEFS = [ - ("Dashboard", "Dash", "dashboard_sl"), - ("Map Data", "Maps", "map_sl"), - ("Vision", "Vision", "vision_sl"), - ("Mapbox", "Mapbox", "mapbox_sl"), - ("Upcoming", "Next", "next_sl"), + ("Dashboard", "Dash", "dashboard_sl", "Dashboard", "dashboard"), + ("Map Data", "Maps", "map_sl", "Map", "map"), + ("Vision", "Vision", "vision_sl", "Camera", "camera"), + ("Mapbox", "Mapbox", "mapbox_sl", "Mapbox", "map"), + ("Upcoming", "Next", "next_sl", "Navigation", "navigation"), ] # Fonts -FONT_LABEL = 28 -FONT_SPEED = 70 -FONT_OFFSET = 50 +FONT_LABEL = 30 +FONT_SPEED = 78 +FONT_OFFSET = 30 FONT_EU_LARGE = 70 FONT_EU_SMALL = 60 FONT_EU_OFFSET = 40 -# Layout -SIGN_MARGIN = 12 - # Vision speed-limit pulse — one-shot purple highlight when the active source # is "Vision" and the resolved value just changed. VISION_SPEED_LIMIT_PULSE_SECONDS = 1.0 @@ -199,7 +192,7 @@ def _get_semi_bold(): return _font_semi_bold -_ACTIVE_SOURCE_LABELS = {title: abbrev.upper() for title, abbrev, _ in SOURCE_DEFS} +_ACTIVE_SOURCE_LABELS = {title: abbrev.upper() for title, abbrev, *_ in SOURCE_DEFS} def _active_source_label(state: dict) -> str: @@ -211,17 +204,41 @@ def _active_source_label(state: dict) -> str: # ── US MUTCD Sign ───────────────────────────────────────────────────── -def _draw_us_sign(x: float, y: float, sign_width: float, speed_text: str, offset_str: str, - source_label: str, alpha: int, show_offset: bool, *, pending: bool = False): - """Draw US-style speed limit sign at (x, y) with the given width. +def _draw_offset_chip(rect: rl.Rectangle, offset_str: str, alpha: int) -> None: + """Draw the optional SLC offset as a compact accent chip.""" + font = _get_semi_bold() + text_size = measure_text_cached(font, offset_str, FONT_OFFSET) + chip_w = max(64.0, text_size.x + 24.0) + chip_h = 38.0 + chip_rect = rl.Rectangle( + rect.x + (rect.width - chip_w) / 2, + rect.y + rect.height - chip_h - 14, + chip_w, + chip_h, + ) + chip_border = rl.Color(255, 255, 255, alpha) + chip_fill = rl.Color(0, 0, 0, min(120, alpha)) + roundness = roundness_for(chip_rect, 18) + rl.draw_rectangle_rounded(chip_rect, roundness, 12, chip_fill) + rl.draw_rectangle_rounded_lines_ex(chip_rect, roundness, 12, 2, chip_border) + rl.draw_text_ex( + font, + offset_str, + rl.Vector2(chip_rect.x + (chip_w - text_size.x) / 2, chip_rect.y + (chip_h - text_size.y) / 2), + FONT_OFFSET, + 0, + chip_border, + ) - Transparent panel (the road shows through) drawn with only an inset - white-on-transparent outline border and white text. Border and text colors - are routed through _speed_limit_pulse_color so a freshly-changed Vision - limit pulses toward VISION_SPEED_LIMIT_PULSE_COLOR for - VISION_SPEED_LIMIT_PULSE_SECONDS. The pending-state blink (white <-> red - border) composes with that pulse. ``alpha`` is the sign-wide opacity - (e.g. 72 when the driver has manually overridden the limit, 255 otherwise). + +def _draw_us_sign(x: float, y: float, sign_width: float, sign_height: float, + speed_text: str, offset_str: str, + source_label: str, alpha: int, show_offset: bool, *, pending: bool = False): + """Draw the NA control card at (x, y). + + The card keeps the SLC's label/value hierarchy while sharing the exact + visible frame geometry with Set Speed. Border and text colors continue to + use the existing Vision pulse and pending blink behavior. """ # Pending: border blinks white <-> red. Active: border is white. if pending: @@ -232,16 +249,13 @@ def _draw_us_sign(x: float, y: float, sign_width: float, speed_text: str, offset # Compose the blink base with the active vision pulse (no-op outside window). border_color = _speed_limit_pulse_color(base_border, alpha) - # White text reads on the transparent background, matching the border color. + # White value text reads on the translucent road background. text_color = _speed_limit_pulse_color(rl.Color(255, 255, 255, 255), alpha) - # No solid fill — the sign is an outline-only overlay on the road. - # Inset outline border; thinner than the outer radius for visual separation. - border_rect = rl.Rectangle(x + US_INSET, y + US_INSET, - sign_width - 2 * US_INSET, - US_SIGN_HEIGHT - 2 * US_INSET) - rl.draw_rectangle_rounded_lines_ex(border_rect, US_INNER_RADIUS / (US_SIGN_HEIGHT - 18), 16, - max(US_BORDER_WIDTH - 2, 1), border_color) + card_rect = rl.Rectangle(x, y, sign_width, sign_height) + card_fill = rl.Color(CONTROL_BG.r, CONTROL_BG.g, CONTROL_BG.b, min(CONTROL_BG.a, alpha)) + draw_control_card(card_rect, fill=card_fill, border=border_color, + border_width=CONTROL_BORDER_WIDTH) font_bold = _get_bold() font_semi = _get_semi_bold() @@ -249,29 +263,29 @@ def _draw_us_sign(x: float, y: float, sign_width: float, speed_text: str, offset # Pending layout: "PENDING" + "LIMIT" + speed (no offset shown when pending). if pending: - pending_size = measure_text_cached(font_semi, tr("PENDING"), FONT_LABEL) - rl.draw_text_ex(font_semi, tr("PENDING"), rl.Vector2(cx - pending_size.x / 2, y + 22), FONT_LABEL, 0, text_color) + pending_size = measure_text_cached(font_semi, tr("PENDING"), FONT_LABEL - 2) + rl.draw_text_ex(font_semi, tr("PENDING"), rl.Vector2(cx - pending_size.x / 2, y + 20), FONT_LABEL - 2, 0, text_color) limit_size = measure_text_cached(font_semi, tr("LIMIT"), FONT_LABEL) - rl.draw_text_ex(font_semi, tr("LIMIT"), rl.Vector2(cx - limit_size.x / 2, y + 51), FONT_LABEL, 0, text_color) - speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED) - rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 85), FONT_SPEED, 0, text_color) + rl.draw_text_ex(font_semi, tr("LIMIT"), rl.Vector2(cx - limit_size.x / 2, y + 48), FONT_LABEL, 0, text_color) + speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED - 6) + rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 85), FONT_SPEED - 6, 0, text_color) elif show_offset: - # Offset ON: source at y=22, speed at y=51, offset at y=120. + # Offset ON: source at the top, speed below it, and the offset in a chip. source_size = measure_text_cached(font_semi, source_label, FONT_LABEL) - rl.draw_text_ex(font_semi, source_label, rl.Vector2(cx - source_size.x / 2, y + 22), FONT_LABEL, 0, text_color) + source_color = _speed_limit_pulse_color(_WHITE, alpha) + rl.draw_text_ex(font_semi, source_label, rl.Vector2(cx - source_size.x / 2, y + 20), FONT_LABEL, 0, source_color) speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED) - rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 51), FONT_SPEED, 0, text_color) - - offset_size = measure_text_cached(font_semi, offset_str, FONT_OFFSET) - rl.draw_text_ex(font_semi, offset_str, rl.Vector2(cx - offset_size.x / 2, y + 120), FONT_OFFSET, 0, text_color) + rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 54), FONT_SPEED, 0, text_color) + _draw_offset_chip(card_rect, offset_str, alpha) else: - # Offset OFF: source at y=22, speed remains at y=85. + # Offset OFF: source at the top, speed centered in the remaining space. source_size = measure_text_cached(font_semi, source_label, FONT_LABEL) - rl.draw_text_ex(font_semi, source_label, rl.Vector2(cx - source_size.x / 2, y + 22), FONT_LABEL, 0, text_color) + source_color = _speed_limit_pulse_color(_WHITE, alpha) + rl.draw_text_ex(font_semi, source_label, rl.Vector2(cx - source_size.x / 2, y + 20), FONT_LABEL, 0, source_color) speed_size = measure_text_cached(font_bold, speed_text, FONT_SPEED) - rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 85), FONT_SPEED, 0, text_color) + rl.draw_text_ex(font_bold, speed_text, rl.Vector2(cx - speed_size.x / 2, y + 78), FONT_SPEED, 0, text_color) # ── EU Vienna Sign ──────────────────────────────────────────────────── @@ -365,107 +379,136 @@ def _draw_sign(state: dict, rect: rl.Rectangle, *, pending: bool = False): _draw_eu_sign(rect.x, rect.y, speed_text, state['offset_str'], source_label, text_alpha, state['show_offset'], pending=pending) else: - _draw_us_sign(rect.x, rect.y, rect.width, speed_text, state['offset_str'], + _draw_us_sign(rect.x, rect.y, rect.width, rect.height, speed_text, state['offset_str'], source_label, text_alpha, state['show_offset'], pending=pending) # ── 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.""" - for dx in (-2, -1, 1, 2): - for dy in (-2, -1, 1, 2): - rl.draw_text_ex(font, text, rl.Vector2(pos.x + dx, pos.y + dy), font_size, 0, outline) - rl.draw_text_ex(font, text, pos, font_size, 0, fill) - - -_BUBBLE_WIDTH_EXTRA = 24 -_BUBBLE_RADIUS = 16 -_BUBBLE_BORDER_WIDTH = 3 -_BUBBLE_SEGMENTS = 16 -_BUBBLE_TEXT_PAD_X = 8 -_BUBBLE_PAD_Y = 8 -_BUBBLE_ACTIVE_WIDTH = 3 -_BUBBLE_GAP = 2 -_BUBBLE_FONT = 34 -_BUBBLE_MIN_FONT = 22 -_BUBBLE_BG = rl.Color(0, 0, 0, 200) -_BUBBLE_BORDER = rl.Color(255, 255, 255, 80) -_BUBBLE_ACTIVE = rl.Color(255, 255, 255, 220) +_SOURCE_PANEL_WIDTH = 300 +_SOURCE_PANEL_GAP = 28 +_SOURCE_PANEL_PAD_X = 18 +_SOURCE_PANEL_PAD_Y = 4 +_SOURCE_PANEL_BG = rl.Color(0, 0, 0, 145) +_SOURCE_DIVIDER = rl.Color(196, 205, 208, 70) +_SOURCE_ICON_MUTED = rl.Color(196, 205, 208, 190) +_SOURCE_LABEL = rl.Color(255, 255, 255, 215) +_SOURCE_FONT = 34 +_SOURCE_MIN_FONT = 22 +_SOURCE_TEXT_GAP = 12 +_SOURCE_ICON_SIZE = 30 def _fit_sources_row(font, label: str, value_text: str, row_h: float, available_w: float): - font_size = min(_BUBBLE_FONT, max(_BUBBLE_MIN_FONT, int(row_h))) + font_size = min(_SOURCE_FONT, max(_SOURCE_MIN_FONT, int(row_h * 0.82))) label_size = measure_text_cached(font, label, font_size) value_size = measure_text_cached(font, value_text, font_size) - needed_w = label_size.x + value_size.x + _BUBBLE_TEXT_PAD_X + needed_w = label_size.x + value_size.x + _SOURCE_TEXT_GAP if needed_w > available_w: - font_size = max(_BUBBLE_MIN_FONT, int(font_size * (available_w / needed_w))) + font_size = max(_SOURCE_MIN_FONT, int(font_size * (available_w / needed_w))) label_size = measure_text_cached(font, label, font_size) value_size = measure_text_cached(font, value_text, font_size) - while label_size.x + value_size.x + _BUBBLE_TEXT_PAD_X > available_w and font_size > _BUBBLE_MIN_FONT: + while label_size.x + value_size.x + _SOURCE_TEXT_GAP > available_w and font_size > _SOURCE_MIN_FONT: font_size -= 1 label_size = measure_text_cached(font, label, font_size) value_size = measure_text_cached(font, value_text, font_size) - fits = label_size.x + value_size.x + _BUBBLE_TEXT_PAD_X <= available_w + fits = label_size.x + value_size.x + _SOURCE_TEXT_GAP <= available_w return font_size, value_size, fits -def _draw_sources_bubble(state: dict, anchor_rect: rl.Rectangle, sign_rect: rl.Rectangle): - """Draw the expanded sources bubble to the right of the source anchor.""" +def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.Color) -> None: + """Draw the small, intentionally simple source glyphs used by the panel.""" + cx = x + size / 2 + cy = y + size / 2 + stroke = max(2.0, size / 12.0) + + if icon_key == "map": + left = x + size * 0.12 + right = x + size * 0.88 + top = y + size * 0.18 + bottom = y + size * 0.82 + fold = size * 0.25 + rl.draw_line_ex(rl.Vector2(left, top), rl.Vector2(left, bottom), stroke, color) + rl.draw_line_ex(rl.Vector2(left, top), rl.Vector2(left + fold, top + size * 0.12), stroke, color) + rl.draw_line_ex(rl.Vector2(left + fold, top + size * 0.12), rl.Vector2(left + fold, bottom + size * 0.12), stroke, color) + rl.draw_line_ex(rl.Vector2(left + fold, top + size * 0.12), rl.Vector2(left + fold * 2.0, top), stroke, color) + rl.draw_line_ex(rl.Vector2(left + fold * 2.0, top), rl.Vector2(left + fold * 2.0, bottom), stroke, color) + rl.draw_line_ex(rl.Vector2(left + fold * 2.0, top), rl.Vector2(right, top + size * 0.12), stroke, color) + rl.draw_line_ex(rl.Vector2(right, top + size * 0.12), rl.Vector2(right, bottom + size * 0.12), stroke, color) + elif icon_key == "camera": + body = rl.Rectangle(x + size * 0.08, y + size * 0.28, size * 0.84, size * 0.54) + rl.draw_rectangle_rounded(body, 0.25, 8, color) + lens = rl.Vector2(cx, y + size * 0.55) + rl.draw_circle_v(lens, size * 0.17, _SOURCE_PANEL_BG) + rl.draw_circle_lines(int(lens.x), int(lens.y), size * 0.17, color) + rl.draw_rectangle_rounded( + rl.Rectangle(x + size * 0.28, y + size * 0.16, size * 0.24, size * 0.17), + 0.25, 6, color, + ) + elif icon_key == "navigation": + head = rl.Vector2(x + size * 0.82, y + size * 0.16) + left = rl.Vector2(x + size * 0.16, y + size * 0.78) + right = rl.Vector2(x + size * 0.64, y + size * 0.84) + rl.draw_triangle(left, head, right, color) + rl.draw_line_ex(left, head, stroke, color) + rl.draw_line_ex(head, right, stroke, color) + else: # Dashboard / fallback + rl.draw_ring(rl.Vector2(cx, cy + size * 0.10), size * 0.27, size * 0.34, 200, 340, 24, color) + rl.draw_line_ex( + rl.Vector2(cx, cy + size * 0.10), + rl.Vector2(cx + size * 0.18, cy - size * 0.12), + stroke, + color, + ) + rl.draw_circle_v(rl.Vector2(cx, cy + size * 0.10), stroke, color) + + +def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle): + """Draw the expanded source list attached to the SLC card.""" font_bold = _get_bold() font_semi = _get_semi_bold() active_source = state['speed_limit_source'] rows = [] - for title, abbrev, value_key in SOURCE_DEFS: + for title, abbrev, value_key, panel_label, icon_key in SOURCE_DEFS: value = state[value_key] if value == 0: continue - rows.append((title, abbrev, value, active_source == title)) + rows.append((title, abbrev, panel_label, icon_key, value, active_source == title)) if not rows: return - bubble_w = sign_rect.width + _BUBBLE_WIDTH_EXTRA - bubble_h = sign_rect.height - - bubble_x = sign_rect.x + sign_rect.width + 12 - bubble_y = sign_rect.y - - bg_rect = rl.Rectangle(bubble_x, bubble_y, bubble_w, bubble_h) - roundness = min(1.0, _BUBBLE_RADIUS / (min(bubble_w, bubble_h) / 2)) - - 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, + panel_rect = rl.Rectangle( + sign_rect.x + sign_rect.width + _SOURCE_PANEL_GAP, + sign_rect.y, + _SOURCE_PANEL_WIDTH, + sign_rect.height, ) - rl.draw_rectangle_rounded(bg_rect, roundness, _BUBBLE_SEGMENTS, _BUBBLE_BG) - rl.draw_rectangle_rounded_lines_ex(bg_rect, roundness, _BUBBLE_SEGMENTS, - _BUBBLE_BORDER_WIDTH, _BUBBLE_BORDER) + rl.draw_rectangle_rounded(panel_rect, roundness_for(panel_rect), 16, _SOURCE_PANEL_BG) - content_h = bubble_h - 2 * (_BUBBLE_BORDER_WIDTH + _BUBBLE_PAD_Y) - row_h = min(44.0, (content_h - (len(rows) - 1) * _BUBBLE_GAP) / len(rows)) - total_content_h = len(rows) * row_h + (len(rows) - 1) * _BUBBLE_GAP - y = bubble_y + (bubble_h - total_content_h) / 2 - content_left = bubble_x + _BUBBLE_BORDER_WIDTH + _BUBBLE_TEXT_PAD_X - content_right = bubble_x + bubble_w - _BUBBLE_BORDER_WIDTH - _BUBBLE_TEXT_PAD_X - available_w = content_right - content_left + row_h = (panel_rect.height - 2 * _SOURCE_PANEL_PAD_Y) / len(rows) + content_left = panel_rect.x + _SOURCE_PANEL_PAD_X + content_right = panel_rect.x + panel_rect.width - _SOURCE_PANEL_PAD_X + label_left = content_left + _SOURCE_ICON_SIZE + _SOURCE_TEXT_GAP + available_w = content_right - label_left - for title, abbrev, value, is_active in rows: - if is_active: - rl.draw_rectangle(int(bubble_x + _BUBBLE_BORDER_WIDTH), int(y), - _BUBBLE_ACTIVE_WIDTH, int(row_h), _BUBBLE_ACTIVE) + for index, (title, abbrev, panel_label, icon_key, value, is_active) in enumerate(rows): + row_y = panel_rect.y + _SOURCE_PANEL_PAD_Y + index * row_h + if index: + divider_y = row_y + rl.draw_line_ex( + rl.Vector2(content_left, divider_y), + rl.Vector2(content_right, divider_y), + 1, + _SOURCE_DIVIDER, + ) text_font = font_bold if is_active else font_semi - label_text = title + label_text = panel_label value_text = str(int(round(value))) - font_size, value_size, fits = _fit_sources_row( text_font, label_text, value_text, row_h, available_w ) @@ -475,19 +518,17 @@ def _draw_sources_bubble(state: dict, anchor_rect: rl.Rectangle, sign_rect: rl.R text_font, label_text, value_text, row_h, available_w ) - label_pos = rl.Vector2(content_left, y + (row_h - font_size) / 2) - value_pos = rl.Vector2(content_right - value_size.x, y + (row_h - font_size) / 2) + baseline_y = row_y + (row_h - font_size) / 2 + icon_y = row_y + (row_h - _SOURCE_ICON_SIZE) / 2 + icon_color = _WHITE if is_active else _SOURCE_ICON_MUTED + _draw_source_icon(icon_key, content_left, icon_y, _SOURCE_ICON_SIZE, icon_color) - text_color = rl.WHITE if is_active else rl.Color(255, 255, 255, 180) - - if is_active: - _draw_text_outlined(text_font, label_text, label_pos, font_size, text_color, rl.Color(0, 0, 0, 255)) - _draw_text_outlined(text_font, value_text, value_pos, font_size, text_color, rl.Color(0, 0, 0, 255)) - else: - rl.draw_text_ex(text_font, label_text, label_pos, font_size, 0, text_color) - rl.draw_text_ex(text_font, value_text, value_pos, font_size, 0, text_color) - - y += row_h + _BUBBLE_GAP + label_pos = rl.Vector2(label_left, baseline_y) + value_pos = rl.Vector2(content_right - value_size.x, baseline_y) + label_color = _SOURCE_LABEL + value_color = _WHITE if is_active else _SOURCE_LABEL + rl.draw_text_ex(text_font, label_text, label_pos, font_size, 0, label_color) + rl.draw_text_ex(text_font, value_text, value_pos, font_size, 0, value_color) # ── Public API ──────────────────────────────────────────────────────── @@ -503,16 +544,10 @@ def render_speed_limit_at(state: dict, rect: rl.Rectangle, expanded: bool = Fals _draw_sign(state, rect, pending=False) use_vienna = state['use_vienna'] - sign_h = EU_SIGN_SIZE if use_vienna else US_SIGN_HEIGHT - visual_w = EU_SIGN_SIZE if use_vienna else rect.width - 2 * US_INSET - visual_x = rect.x if use_vienna else rect.x + US_INSET - visual_rect = rl.Rectangle(visual_x, rect.y, visual_w, sign_h) + visual_rect = rl.Rectangle(rect.x, rect.y, EU_SIGN_SIZE, EU_SIGN_SIZE) if use_vienna else rect source = state.get('speed_limit_source') if expanded and source and source != "None" and source != "": - source_y = visual_rect.y + (16 if use_vienna else 22) - source_h = FONT_LABEL - 4 if use_vienna else FONT_LABEL - source_anchor = rl.Rectangle(visual_rect.x, source_y, visual_rect.width, source_h) - _draw_sources_bubble(state, source_anchor, visual_rect) + _draw_sources_bubble(state, visual_rect) return visual_rect diff --git a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py index 7536801cc..bd7ade88a 100644 --- a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py +++ b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py @@ -1,5 +1,6 @@ import pyray as rl from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import WIDGET_ANCHOR_OFFSET class WidgetLayoutManager: def __init__(self, content_rect: rl.Rectangle): @@ -27,8 +28,8 @@ class WidgetLayoutManager: 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 + # X anchor is the shared left-control center (content x + 146). + center_x = self.content_rect.x + WIDGET_ANCHOR_OFFSET current_y = self.content_rect.y + 45 for widget in active_widgets: diff --git a/selfdrive/ui/onroad/starpilot/widget_style.py b/selfdrive/ui/onroad/starpilot/widget_style.py new file mode 100644 index 000000000..ab963a3b9 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widget_style.py @@ -0,0 +1,32 @@ +"""Shared visual tokens for the StarPilot on-road control widgets.""" + +import pyray as rl + + +# The two left-hand control cards share a visible frame. Keeping these values +# here prevents the Set Speed and SLC implementations from drifting apart. +CONTROL_WIDTH = 176 +SET_SPEED_HEIGHT = 196 +SLC_HEIGHT = 216 +CONTROL_RADIUS = 16 +CONTROL_SEGMENTS = 16 +CONTROL_BORDER_WIDTH = 3 +CONTROL_BG = rl.Color(0, 0, 0, 166) +CONTROL_BORDER = rl.Color(196, 205, 208, 180) +# The layout manager has historically anchored the left controls at x + 146. +# Keep that placement stable while making the width explicit and shared. +WIDGET_ANCHOR_OFFSET = 146 + + +def roundness_for(rect: rl.Rectangle, radius: float = CONTROL_RADIUS) -> float: + """Convert a pixel corner radius to Raylib's normalized roundness value.""" + return min(1.0, radius / max(1.0, min(rect.width, rect.height) / 2.0)) + + +def draw_control_card(rect: rl.Rectangle, *, fill: rl.Color = CONTROL_BG, + border: rl.Color = CONTROL_BORDER, + border_width: float = CONTROL_BORDER_WIDTH) -> None: + """Draw the common translucent rounded card used by left-hand controls.""" + roundness = roundness_for(rect) + rl.draw_rectangle_rounded(rect, roundness, CONTROL_SEGMENTS, fill) + rl.draw_rectangle_rounded_lines_ex(rect, roundness, CONTROL_SEGMENTS, border_width, border) diff --git a/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py b/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py index 168d5874c..496727564 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py +++ b/selfdrive/ui/onroad/starpilot/widgets/aethergauge.py @@ -3,6 +3,7 @@ from openpilot.common.filter_simple import FirstOrderFilter 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, _fade +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import CONTROL_WIDTH class AetherGaugeWidget(LayoutWidget): def __init__(self, hud_renderer): @@ -18,8 +19,8 @@ class AetherGaugeWidget(LayoutWidget): return self._aethergauge.has_active_source() def get_size(self) -> tuple[float, float]: - # Width 172 aligns with SetSpeedWidget; height 260 covers the road visual and text cradle - return 172.0, 260.0 + # Match the left control width; height covers the road visual and text cradle. + return float(CONTROL_WIDTH), 260.0 def _render(self, rect: rl.Rectangle) -> None: target = 1.0 if self._aethergauge.has_active_source() else 0.0 diff --git a/selfdrive/ui/onroad/starpilot/widgets/set_speed.py b/selfdrive/ui/onroad/starpilot/widgets/set_speed.py index 9c6690f08..1a66fed87 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/set_speed.py +++ b/selfdrive/ui/onroad/starpilot/widgets/set_speed.py @@ -7,6 +7,7 @@ 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 ) +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import draw_control_card class SetSpeedWidget(LayoutWidget): def __init__(self, hud_renderer): @@ -31,8 +32,7 @@ class SetSpeedWidget(LayoutWidget): 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) + draw_control_card(rect) max_color = COLORS.GREY set_speed_color = COLORS.DARK_GREY diff --git a/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py b/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py index acbf291c4..5d29f9270 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py +++ b/selfdrive/ui/onroad/starpilot/widgets/speed_limit.py @@ -4,10 +4,9 @@ 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 + _get_slc_state, render_speed_limit_at, EU_SIGN_SIZE, ) +from openpilot.selfdrive.ui.onroad.starpilot.widget_style import CONTROL_WIDTH, SLC_HEIGHT class SpeedLimitWidget(LayoutWidget): @@ -42,11 +41,8 @@ class SpeedLimitWidget(LayoutWidget): 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) + w = float(EU_SIGN_SIZE if use_vienna else CONTROL_WIDTH) + h = float(EU_SIGN_SIZE if use_vienna else SLC_HEIGHT) return w, h