Initial cruise set speed scroller ui (#39098)

* Revert " A New Dawn"

This reverts commit f50108b4af.

* Revert "Add configurable initial max speed (#39081)"

This reverts commit 24c26487f4.

* add back some stuff and new ui

* exp mode removal again

* restore tici

* rm exp mode and add new designs, fix color bug for tici

* dedup

* more dedup

* round values when toggling between metric!

* nick design tm

* inlining

* simplification

* fix

* cmts

* clr

* clr

* remove taparea

* rm

* rm

* clean up
This commit is contained in:
Shane Smiskol
2026-10-15 20:51:41 -07:00
committed by GitHub
parent f5bcd10a2a
commit fcf4b0d395
6 changed files with 223 additions and 69 deletions
@@ -1,29 +1,49 @@
import math
import time
import pyray as rl
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL, V_CRUISE_MAX
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import FontWeight, TextAlignment
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.list_view import ItemAction
from openpilot.system.ui.widgets.list_view import ItemAction, BUTTON_HEIGHT, BUTTON_BORDER_RADIUS
SPEED_STEP = 5
STEP_BUTTON_WIDTH = 160
VALUE_FONT_SIZE = 50
REPEAT_DELAY = 0.5 # seconds held before repeating
REPEAT_INTERVAL = 0.2 # seconds
def _speed_conversion() -> float:
return 1 if ui_state.is_metric else CV.KPH_TO_MPH
def get_speed_options() -> tuple[list[int], int]:
"""Initial cruise speeds in display units, and the index of the nearest to the saved one"""
conversion = _speed_conversion()
options = list(range(round(V_CRUISE_INITIAL * conversion), round(V_CRUISE_MAX * conversion) + 1, SPEED_STEP))
idx = round((ui_state.initial_cruise_speed * conversion - options[0]) / SPEED_STEP)
return options, max(0, min(len(options) - 1, idx))
def set_initial_cruise_speed(speed: int) -> None:
"""Save an initial cruise speed in display units"""
ui_state.params.put("InitialCruiseSpeedKph", round(speed / _speed_conversion()), block=True)
ui_state.update_params()
class RepeatButton(Button):
def __init__(self, text, callback, font_size):
super().__init__(text, callback, font_size=font_size, button_style=ButtonStyle.PILL, border_radius=16, text_padding=0)
super().__init__(text, callback, font_size=font_size, button_style=ButtonStyle.PILL, border_radius=BUTTON_BORDER_RADIUS, text_padding=0)
self._repeat_at: float | None = None
self._repeated = False
def cancel_repeat(self):
self._repeat_at = None
self._repeated = True
def _handle_mouse_press(self, mouse_pos):
self._repeat_at = time.monotonic() + 0.5
super()._handle_mouse_press(mouse_pos)
self._repeat_at = time.monotonic() + REPEAT_DELAY
self._repeated = False
def _handle_mouse_release(self, mouse_pos):
@@ -32,49 +52,39 @@ class RepeatButton(Button):
def _process_mouse_events(self):
super()._process_mouse_events()
# step repeatedly while held. once it has repeated, the release doesn't step again
if not self.is_pressed or not self._touch_valid():
self.cancel_repeat()
self._repeat_at = None
elif self._repeat_at is not None and time.monotonic() >= self._repeat_at:
self._repeated = True
self._repeat_at = time.monotonic() + 0.2
self._repeat_at = time.monotonic() + REPEAT_INTERVAL
if self._click_callback:
self._click_callback()
class InitialCruiseSpeedControl(ItemAction):
def __init__(self, compact: bool = False):
super().__init__(width=322 if compact else 580)
self._params = Params()
self._button_size = 64 if compact else 100
self._font_size = 36 if compact else 50
def __init__(self):
super().__init__(width=580)
self._options, self._selected_idx = get_speed_options()
self._decrease = self._child(RepeatButton("-", lambda: self._adjust_speed(-5), self._font_size))
self._increase = self._child(RepeatButton("+", lambda: self._adjust_speed(5), self._font_size))
self._decrease.set_enabled(lambda: self.enabled and self._value > self._minimum)
self._increase.set_enabled(lambda: self.enabled and self._value < self._maximum)
self._decrease = self._child(RepeatButton("-", lambda: self._adjust_speed(-1), VALUE_FONT_SIZE))
self._increase = self._child(RepeatButton("+", lambda: self._adjust_speed(1), VALUE_FONT_SIZE))
self._decrease.set_enabled(lambda: self.enabled and self._selected_idx > 0)
self._increase.set_enabled(lambda: self.enabled and self._selected_idx < len(self._options) - 1)
self._decrease.set_touch_valid_callback(self._touch_valid)
self._increase.set_touch_valid_callback(self._touch_valid)
self.refresh()
def refresh(self, is_metric: bool | None = None):
for button in (self._decrease, self._increase):
button.cancel_repeat()
self._metric = self._params.get_bool("IsMetric") if is_metric is None else is_metric
self._conversion = 1 if self._metric else CV.KPH_TO_MPH
self._minimum = round(V_CRUISE_INITIAL * self._conversion)
self._maximum = round(V_CRUISE_MAX * self._conversion)
self._value = max(self._minimum, round(self._params.get("InitialCruiseSpeedKph", return_default=True) * self._conversion))
def refresh(self):
self._options, self._selected_idx = get_speed_options()
def _adjust_speed(self, delta: int):
round_func = math.floor if delta > 0 else math.ceil
self._value = round_func(self._value / 5) * 5 + delta
self._params.put("InitialCruiseSpeedKph", round(self._value / self._conversion), block=True)
def _adjust_speed(self, step: int):
self._selected_idx += step
set_initial_cruise_speed(self._options[self._selected_idx])
def _render(self, rect):
size = self._button_size
y = rect.y + (rect.height - size) / 2
self._decrease.render(rl.Rectangle(rect.x, y, size, size))
self._increase.render(rl.Rectangle(rect.x + rect.width - size, y, size, size))
text = f"{self._value} " + (tr("km/h") if self._metric else tr("mph"))
gui_label(rl.Rectangle(rect.x + size, y, rect.width - 2 * size, size), text, self._font_size,
y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
self._decrease.render(rl.Rectangle(rect.x, y, STEP_BUTTON_WIDTH, BUTTON_HEIGHT))
self._increase.render(rl.Rectangle(rect.x + rect.width - STEP_BUTTON_WIDTH, y, STEP_BUTTON_WIDTH, BUTTON_HEIGHT))
text = f"{self._options[self._selected_idx]} " + (tr("km/h") if ui_state.is_metric else tr("mph"))
gui_label(rl.Rectangle(rect.x + STEP_BUTTON_WIDTH, y, rect.width - 2 * STEP_BUTTON_WIDTH, BUTTON_HEIGHT), text, VALUE_FONT_SIZE,
color=rl.WHITE if self.enabled else rl.GRAY, font_weight=FontWeight.MEDIUM, alignment=TextAlignment.CENTER)
@@ -174,7 +174,7 @@ class TogglesLayout(Widget):
def _toggle_callback(self, state: bool, param: str):
self._params.put_bool(param, state, block=True)
if param == "IsMetric":
self._initial_cruise_speed.action_item.refresh()
self._update_toggles()
if self._toggle_defs[param][3]:
self._params.put_bool("OnroadCycleRequested", True, block=True)
@@ -0,0 +1,160 @@
import math
import pyray as rl
from collections.abc import Callable
from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import get_speed_options, set_initial_cruise_speed
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel2 import AUTO_SCROLL_TC, GuiScrollPanel2, ScrollState
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
ITEM_DRAG_DISTANCE = 120 # px of drag per value
ITEM_GAP = 20 # px between adjacent values
VALUE_CENTER_Y = 102
VISIBLE_VALUES = 3 # on each side of center value
# value styles
CENTER_FONT_SIZE = 120
CENTER_LETTER_SPACING = -0.03
SIDE_FONT_SIZE = 96
SIDE_LETTER_SPACING = -0.01
def lerp(start: float, end: float, t: float) -> float:
return start + (end - start) * t
class SpeedScroller(Widget):
def __init__(self, change_callback: Callable[[], None]):
super().__init__()
self._change_callback = change_callback
self._center_font = gui_app.font(FontWeight.BOLD)
self._side_font = gui_app.font(FontWeight.ROMAN)
self._scroll_panel = GuiScrollPanel2(horizontal=True)
self._scroll_panel.set_enabled(lambda: self.enabled)
self.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
self.set_click_callback(self._on_tap)
self._options: list[int] = []
self._center_widths: list[float] = []
self._side_widths: list[float] = []
self._locked_idx: int | None = None # value the scroller is easing onto, None while the user drags
def show_event(self):
super().show_event()
self._options, self._locked_idx = get_speed_options()
self._center_widths = [measure_text_cached(self._center_font, str(option), CENTER_FONT_SIZE,
CENTER_LETTER_SPACING * CENTER_FONT_SIZE * FONT_SCALE).x for option in self._options]
self._side_widths = [measure_text_cached(self._side_font, str(option), SIDE_FONT_SIZE,
SIDE_LETTER_SPACING * SIDE_FONT_SIZE * FONT_SCALE).x for option in self._options]
self._scroll_panel.set_offset(-self._locked_idx * ITEM_DRAG_DISTANCE)
def _clamp_index(self, idx: int) -> int:
return max(0, min(len(self._options) - 1, idx))
def _lock(self, idx: int):
self._locked_idx = self._clamp_index(idx)
set_initial_cruise_speed(self._options[self._locked_idx])
self._change_callback()
def _update_state(self):
super()._update_state()
# when the finger releases, lock the value where the scroll will stop
if self._scroll_panel.state == ScrollState.MANUAL_SCROLL:
self._locked_idx = None
elif self._locked_idx is None and self._scroll_panel.state != ScrollState.PRESSED:
projected_offset = self._scroll_panel.get_offset() + self._scroll_panel.velocity * AUTO_SCROLL_TC
self._lock(round(-projected_offset / ITEM_DRAG_DISTANCE))
snap_target = -self._locked_idx * ITEM_DRAG_DISTANCE if self._locked_idx is not None else None
content_size = (len(self._options) - 1) * ITEM_DRAG_DISTANCE + self._rect.width
self._scroll_panel.update(self._rect, content_size, snap_target=snap_target)
def _on_tap(self):
if self._locked_idx is None:
return
# a tap on the left or the right moves one value
tap_x = gui_app.last_mouse_event.pos.x
center_x = self._rect.x + self._rect.width / 2
tap_zone = self._center_widths[self._locked_idx] / 2 + ITEM_GAP / 2
if tap_x > center_x + tap_zone:
self._lock(self._locked_idx + 1)
elif tap_x < center_x - tap_zone:
self._lock(self._locked_idx - 1)
def _render(self, _):
center_x = self._rect.x + self._rect.width / 2
current = -self._scroll_panel.get_offset() / ITEM_DRAG_DISTANCE
# the style of each value changes from the center style to the side style over a distance of one value
blends = [min(abs(idx - current), 1.0) for idx in range(len(self._options))]
widths = [lerp(center, side, t) for center, side, t in zip(self._center_widths, self._side_widths, blends, strict=True)]
# lay values out with a constant gap, then shift so the scroll position sits at the center
centers = [0.0]
for idx in range(1, len(widths)):
centers.append(centers[-1] + widths[idx - 1] / 2 + ITEM_GAP + widths[idx] / 2)
left_idx = max(0, min(len(widths) - 2, math.floor(current)))
shift = center_x - lerp(centers[left_idx], centers[left_idx + 1], current - left_idx)
# only the values near the center can be on the screen. the nearest value uses the bold font
closest_idx = self._clamp_index(round(current))
for idx in range(max(0, closest_idx - VISIBLE_VALUES), min(len(self._options), closest_idx + VISIBLE_VALUES + 1)):
t = blends[idx]
font_size = lerp(CENTER_FONT_SIZE, SIDE_FONT_SIZE, t)
spacing = lerp(CENTER_LETTER_SPACING, SIDE_LETTER_SPACING, t) * font_size * FONT_SCALE
color = rl.Color(255, 255, 255, int(255 * lerp(0.9, 0.45, t)))
# use the width of the font that draws the value. this keeps the value at its center when the font changes
if idx == closest_idx:
font, width = self._center_font, self._center_widths[idx] * font_size / CENTER_FONT_SIZE
else:
font, width = self._side_font, self._side_widths[idx] * font_size / SIDE_FONT_SIZE
position = rl.Vector2(centers[idx] + shift - width / 2, self._rect.y + VALUE_CENTER_Y - font_size * FONT_SCALE / 2)
rl.draw_text_ex(font, str(self._options[idx]), position, font_size, spacing, color)
# gradient on left and right
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), 100, int(self._rect.height), rl.BLACK, rl.BLANK)
rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - 100), int(self._rect.y), 100, int(self._rect.height), rl.BLANK, rl.BLACK)
class InitialCruiseSpeedPicker(BigDialogBase):
def __init__(self, change_callback: Callable[[], None]):
super().__init__()
self._unit_label = self._child(UnifiedLabel("", 36, FontWeight.BOLD, LABEL_COLOR, alignment=TextAlignment.CENTER, letter_spacing=-0.03))
self._speed_scroller = self._child(SpeedScroller(change_callback))
self._speed_scroller.set_enabled(lambda: self.enabled and not self.is_dismissing)
def show_event(self):
super().show_event()
self._unit_label.set_text(tr("KPH") if ui_state.is_metric else tr("MPH"))
def _render(self, _):
self._speed_scroller.render(self._rect)
self._unit_label.render(rl.Rectangle(self._rect.x, self._rect.y + 166, self._rect.width, self._rect.height - 166))
class InitialCruiseSpeedButton(BigButton):
def __init__(self):
super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" +
"If you are already driving faster, your current speed is used.\n" +
"Resume restores the previous max speed.")
self._picker = InitialCruiseSpeedPicker(self.refresh)
self.set_click_callback(lambda: gui_app.push_widget(self._picker))
self.refresh()
def _get_label_font_size(self):
return 42
def refresh(self):
options, idx = get_speed_options()
self.set_value(f"{options[idx]} " + (tr("kph") if ui_state.is_metric else tr("mph")))
@@ -1,41 +1,15 @@
from openpilot.cereal import log
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl, BigMultiParamToggle
from openpilot.selfdrive.ui.mici.widgets.button import BigToggle, BigParamControl, BigMultiParamToggle
from openpilot.system.ui.lib.application import gui_app
from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControl
from openpilot.selfdrive.ui.mici.layouts.settings.initial_cruise_speed import InitialCruiseSpeedButton
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
class InitialCruiseSpeedButton(BigButton):
def __init__(self):
super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" +
"If you are already driving faster, your current speed is used.\n" +
"Resume restores the previous max speed.")
self._control = self._child(InitialCruiseSpeedControl(compact=True))
self._control.set_enabled(lambda: self.enabled)
self._control.set_touch_valid_callback(self._touch_valid)
self._show_description = self._long_press_callback
self.set_long_press_callback(self._on_long_press)
def _on_long_press(self):
# a hold on the speed control repeats a step
if not self._control.is_pressed and self._show_description is not None:
self._show_description()
def _get_label_font_size(self):
return 36
def _draw_subtitle(self, rect):
self._control.render(rect)
def refresh(self, is_metric: bool | None = None):
self._control.refresh(is_metric)
class TogglesLayoutMici(NavScroller):
def __init__(self):
super().__init__()
@@ -47,7 +21,7 @@ class TogglesLayoutMici(NavScroller):
"Use the steering wheel distance button on supported cars.")
self._initial_cruise_speed = InitialCruiseSpeedButton()
self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise)
is_metric_toggle = BigParamControl("use metric units", "IsMetric", toggle_callback=self._initial_cruise_speed.refresh)
is_metric_toggle = BigToggle("use metric units", initial_state=ui_state.params.get_bool("IsMetric"), toggle_callback=self._on_metric_toggle)
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled",
description="Warn when you drift across a detected lane line.\n" +
"Only above 31 mph (50 km/h), with no turn signal.")
@@ -107,6 +81,10 @@ class TogglesLayoutMici(NavScroller):
super().show_event()
self._update_toggles()
def _on_metric_toggle(self, state: bool):
ui_state.params.put_bool("IsMetric", state, block=True)
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
self._initial_cruise_speed.refresh()
+2
View File
@@ -86,6 +86,7 @@ class UIState:
# Core state variables
self.is_metric: bool = self.params.get_bool("IsMetric")
self.initial_cruise_speed: int = self.params.get("InitialCruiseSpeedKph", return_default=True)
self.is_release = self.params.get_bool("IsReleaseBranch")
self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
self.navigation_enabled: bool = self.params.get_bool("NavigationEnabled")
@@ -249,6 +250,7 @@ class UIState:
self.recording_audio = self.params.get_bool("RecordAudio") and self.started
self.is_metric = self.params.get_bool("IsMetric")
self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True)
self.always_on_dm = self.params.get_bool("AlwaysOnDM")
self.navigation_enabled = self.params.get_bool("NavigationEnabled")
self.nav_destination_set = self.params.get("NavDestination") is not None
+4
View File
@@ -263,6 +263,10 @@ class GuiScrollPanel2:
def state(self) -> ScrollState:
return self._state
@property
def velocity(self) -> float:
return self._velocity
def is_touch_valid(self) -> bool:
# MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state
return bool(self._state != ScrollState.MANUAL_SCROLL)