diff --git a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py index fb30e2c524..42ece18eb5 100644 --- a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py +++ b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py @@ -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) diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index 4d6c2812fc..b7d864bc91 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -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) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py new file mode 100644 index 0000000000..e366738893 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py @@ -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"))) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index a491e2ac37..e1b95bf472 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -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() diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index c093253771..bc64de5e29 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -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 diff --git a/openpilot/system/ui/lib/scroll_panel2.py b/openpilot/system/ui/lib/scroll_panel2.py index 19faaa542d..b53e663794 100644 --- a/openpilot/system/ui/lib/scroll_panel2.py +++ b/openpilot/system/ui/lib/scroll_panel2.py @@ -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)