ui: add experimental mode toggle button with visual indicator (#35446)

* add experimental mode toggle button with visual indicator

* merge master

* implement a temporary state hold after mouse click"

* move to seperate class

---------

Co-authored-by: Shane Smiskol <shane@smiskol.com>
This commit is contained in:
Dean Lee
2025-06-07 13:14:18 +08:00
committed by GitHub
parent 7875cc4713
commit 2031a33188
4 changed files with 104 additions and 22 deletions
+4 -9
View File
@@ -38,7 +38,6 @@ class MainLayout(Widget):
self._update_layout_rects(rect)
self._handle_onroad_transition()
self._render_main_content()
self._handle_input()
if self._current_callback:
self._current_callback()
@@ -51,6 +50,7 @@ class MainLayout(Widget):
self._layouts[MainState.SETTINGS].set_callbacks(
on_close=lambda: setattr(self, '_current_callback', self._set_mode_for_state)
)
self._layouts[MainState.ONROAD].on_click = self._on_onrad_clicked
def _update_layout_rects(self, rect):
self._window_rect = rect
@@ -80,6 +80,9 @@ class MainLayout(Widget):
def _on_flag_clicked(self):
pass
def _on_onrad_clicked(self):
self._sidebar_visible = not self._sidebar_visible
def _render_main_content(self):
# Render sidebar
if self._sidebar_visible:
@@ -87,11 +90,3 @@ class MainLayout(Widget):
content_rect = self._content_rect if self._sidebar_visible else self._window_rect
self._layouts[self._current_mode].render(content_rect)
def _handle_input(self):
if self._current_mode != MainState.ONROAD or not rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
return
mouse_pos = rl.get_mouse_position()
if rl.check_collision_point_rec(mouse_pos, self._content_rect):
self._sidebar_visible = not self._sidebar_visible
+10 -1
View File
@@ -1,6 +1,6 @@
import numpy as np
import pyray as rl
from collections.abc import Callable
from cereal import log
from msgq.visionipc import VisionStreamType
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, UI_BORDER_SIZE
@@ -49,6 +49,9 @@ class AugmentedRoadView(CameraView):
self.alert_renderer = AlertRenderer()
self.driver_state_renderer = DriverStateRenderer()
# Callbacks
self.on_click: Callable | None = None
def render(self, rect):
# Only render when system is started to avoid invalid data access
if not ui_state.started:
@@ -94,6 +97,12 @@ class AugmentedRoadView(CameraView):
# End clipping region
rl.end_scissor_mode()
# Handle click events if no HUD interaction occurred
if not self._hud_renderer.handle_mouse_event():
if self.on_click and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._content_rect):
self.on_click()
def _draw_border(self, rect: rl.Rectangle):
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color)
+75
View File
@@ -0,0 +1,75 @@
import time
import pyray as rl
from cereal.messaging import SubMaster
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app
from openpilot.common.params import Params
class ExpButton:
def __init__(self, button_size: int, icon_size: int):
self._params = Params()
self._experimental_mode: bool = False
self._engageable: bool = False
# State hold mechanism
self._hold_duration = 2.0 # seconds
self._held_mode: bool | None = None
self._hold_end_time: float | None = None
self._white_color: rl.Color = rl.Color(255, 255, 255, 255)
self._black_bg: rl.Color = rl.Color(0, 0, 0, 166)
self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size)
self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size)
self._rect: rl.Rectangle = rl.Rectangle(0, 0, button_size, button_size)
def update_state(self, sm: SubMaster) -> None:
selfdrive_state = sm["selfdriveState"]
self._experimental_mode = selfdrive_state.experimentalMode
self._engageable = selfdrive_state.engageable or selfdrive_state.enabled
def handle_mouse_event(self) -> bool:
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect):
if (rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and
self._is_toggle_allowed()):
new_mode = not self._experimental_mode
self._params.put_bool("ExperimentalMode", new_mode)
# Hold new state temporarily
self._held_mode = new_mode
self._hold_end_time = time.time() + self._hold_duration
return True
return False
def draw(self, x: int, y: int) -> None:
self._rect.x, self._rect.y = x, y
center_x = int(self._rect.x + self._rect.width // 2)
center_y = int(self._rect.y + self._rect.height // 2)
mouse_over = rl.check_collision_point_rec(rl.get_mouse_position(), self._rect)
mouse_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
self._white_color.a = 180 if (mouse_down and mouse_over) or not self._engageable else 255
texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel
rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg)
rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color)
def _held_or_actual_mode(self):
now = time.time()
if self._hold_end_time and now < self._hold_end_time:
return self._held_mode
if self._hold_end_time and now >= self._hold_end_time:
self._hold_end_time = self._held_mode = None
return self._experimental_mode
def _is_toggle_allowed(self):
if not self._params.get_bool("ExperimentalModeConfirmed"):
return False
car_params = ui_state.sm["carParams"]
if car_params.alphaLongitudinalAvailable:
return self._params.get_bool("AlphaLongitudinalEnabled")
else:
return car_params.openpilotLongitudinalControl
+15 -12
View File
@@ -2,6 +2,7 @@ import pyray as rl
from dataclasses import dataclass
from cereal.messaging import SubMaster
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.onroad.exp_button import ExpButton
from openpilot.system.ui.lib.application import gui_app, FontWeight, Widget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.common.conversions import Conversions as CV
@@ -62,11 +63,13 @@ class HudRenderer(Widget):
self.set_speed: float = SET_SPEED_NA
self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self._wheel_texture: rl.Texture = gui_app.texture('icons/chffr_wheel.png', UI_CONFIG.wheel_icon_size, UI_CONFIG.wheel_icon_size)
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(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size)
def _update_state(self, sm: SubMaster) -> None:
"""Update HUD state based on car state and controls state."""
if sm.recv_frame["carState"] < ui_state.started_frame:
@@ -94,9 +97,13 @@ class HudRenderer(Widget):
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)
self._exp_button.update_state(sm)
def render(self, rect: rl.Rectangle) -> None:
"""Render HUD elements to the screen."""
self._update_state(ui_state.sm)
# Draw the header background
rl.draw_rectangle_gradient_v(
int(rect.x),
int(rect.y),
@@ -110,7 +117,13 @@ class HudRenderer(Widget):
self._draw_set_speed(rect)
self._draw_current_speed(rect)
self._draw_wheel_icon(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.draw(button_x, button_y)
def handle_mouse_event(self) -> bool:
return bool(self._exp_button.handle_mouse_event())
def _draw_set_speed(self, rect: rl.Rectangle) -> None:
"""Draw the MAX speed indicator box."""
@@ -166,13 +179,3 @@ class HudRenderer(Widget):
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)
def _draw_wheel_icon(self, rect: rl.Rectangle) -> None:
"""Draw the steering wheel icon with status-based opacity."""
center_x = int(rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size / 2)
center_y = int(rect.y + UI_CONFIG.border_size + UI_CONFIG.button_size / 2)
rl.draw_circle(center_x, center_y, UI_CONFIG.button_size / 2, COLORS.black_translucent)
opacity = 0.7 if ui_state.status == UIStatus.DISENGAGED else 1.0
img_pos = rl.Vector2(center_x - self._wheel_texture.width / 2, center_y - self._wheel_texture.height / 2)
rl.draw_texture_v(self._wheel_texture, img_pos, rl.Color(255, 255, 255, int(255 * opacity)))