mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-10-21 22:23:44 +08:00
Initial cruise set speed scroller ui (#39098)
* Revert " A New Dawn" This reverts commitf50108b4af. * Revert "Add configurable initial max speed (#39081)" This reverts commit24c26487f4. * 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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user