diff --git a/selfdrive/assets/icons_mici/settings/horizontal_scroll_indicator.png b/selfdrive/assets/icons_mici/settings/horizontal_scroll_indicator.png new file mode 100644 index 00000000..4b848657 Binary files /dev/null and b/selfdrive/assets/icons_mici/settings/horizontal_scroll_indicator.png differ diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py index b1c4cab5..05f99bec 100644 --- a/selfdrive/ui/mici/layouts/settings/developer.py +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -91,7 +91,7 @@ class DeveloperLayoutMici(NavWidget): self._long_maneuver_toggle, self._alpha_long_toggle, self._debug_mode_toggle, - ], snap_items=False) + ], snap_items=False, scroll_indicator=True, edge_shadows=True) # Toggle lists self._refresh_toggles = ( diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index e37869ee..70a8e8ca 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -76,6 +76,8 @@ def _engaged_confirmation_callback(callback: Callable, action_text: str): icon = "icons_mici/settings/device/reboot.png" elif action_text == "reset": icon = "icons_mici/settings/device/lkas.png" + elif action_text == "reset driver monitoring": + icon = "icons_mici/settings/device/cameras.png" elif action_text == "uninstall": icon = "icons_mici/settings/device/uninstall.png" else: @@ -83,7 +85,7 @@ def _engaged_confirmation_callback(callback: Callable, action_text: str): icon = "icons_mici/settings/comma_icon.png" dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red, - exit_on_confirm=action_text == "reset", + exit_on_confirm=action_text in {"reset", "reset driver monitoring"}, confirm_callback=confirm_callback) gui_app.set_modal_overlay(dlg) else: @@ -459,9 +461,17 @@ class DeviceLayoutMici(NavWidget): params.remove("LiveDelay") params.put_bool("OnroadCycleRequested", True) + def reset_driver_monitoring_callback(): + params = ui_state.params + params.remove("IsRhdDetected") + params.put_bool("OnroadCycleRequested", True) + def uninstall_openpilot_callback(): ui_state.params.put_bool("DoUninstall", True) + reset_driver_monitoring_btn = BigButton("reset driver monitoring calibration", "", "icons_mici/settings/device/cameras.png") + reset_driver_monitoring_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_driver_monitoring_callback, "reset driver monitoring")) + reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png") reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset")) @@ -508,13 +518,14 @@ class DeviceLayoutMici(NavWidget): PairBigButton(), review_training_guide_btn, driver_cam_btn, + reset_driver_monitoring_btn, # lang_button, reset_calibration_btn, uninstall_openpilot_btn, regulatory_btn, reboot_btn, self._power_off_btn, - ], snap_items=False) + ], snap_items=False, scroll_indicator=True, edge_shadows=True) # Set up back navigation self.set_back_callback(back_callback) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 1faf4931..0326f71b 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -3,7 +3,7 @@ from enum import IntEnum from collections.abc import Callable from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici, WifiIcon, normalize_ssid from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle, BigParamControl from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state @@ -75,8 +75,14 @@ class NetworkLayoutMici(NavWidget): self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) self._network_metered_btn.set_enabled(False) - wifi_button = BigButton("wi-fi") + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 64, 56) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 64, 47) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) + + wifi_button = BigButton("wi-fi", "not connected", self._wifi_slash_txt) wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI)) + self._wifi_button = wifi_button # ******** Advanced settings ******** # ******** Roaming toggle ******** @@ -101,7 +107,7 @@ class NetworkLayoutMici(NavWidget): self._cellular_metered_btn, # */ self._ip_address_btn, - ], snap_items=False) + ], snap_items=False, scroll_indicator=True, edge_shadows=True) # Set initial config roaming_enabled = ui_state.params.get_bool("GsmRoaming") diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 41267f16..3471b348 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -90,7 +90,7 @@ class SettingsLayout(NavWidget): #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), firehose_btn, developer_btn, - ], snap_items=False) + ], snap_items=False, scroll_indicator=True, edge_shadows=True) # Set up back navigation self.set_back_callback(self.close_settings) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index c16504fa..b847fe74 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -35,7 +35,7 @@ class TogglesLayoutMici(NavWidget): record_front, record_mic, enable_openpilot, - ], snap_items=False) + ], snap_items=False, scroll_indicator=True, edge_shadows=True) # Toggle lists self._refresh_toggles = ( diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py index be08e0fe..b500f143 100644 --- a/selfdrive/ui/mici/widgets/button.py +++ b/selfdrive/ui/mici/widgets/button.py @@ -110,6 +110,7 @@ class BigButton(Widget): self.text = text self.value = value self.set_icon(icon) + self._label_font_size_override: int | None = None self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) @@ -136,6 +137,18 @@ class BigButton(Widget): def set_icon(self, icon: Union[str, rl.Texture]): self._txt_icon = gui_app.texture(icon, 64, 64) if isinstance(icon, str) and len(icon) else icon + def _refresh_label_metrics(self): + font_size = self._label_font_size_override if self._label_font_size_override is not None else self._get_label_font_size() + self._label.set_font_size(font_size) + self._needs_scroll = measure_text_cached(self._label_font, self.text, font_size).x + 25 > self._rect.width + self._scroll_offset = 0 + self._scroll_timer = 0 + self._scroll_state = ScrollState.PRE_SCROLL + + def _set_label_font_size_override(self, font_size: int | None): + self._label_font_size_override = font_size + self._refresh_label_metrics() + def set_rotate_icon(self, rotate: bool): if rotate and self._rotate_icon_t is not None: return @@ -165,10 +178,12 @@ class BigButton(Widget): def set_text(self, text: str): self.text = text self._label.set_text(text) + self._refresh_label_metrics() def set_value(self, value: str): self.value = value self._sub_label.set_text(value) + self._refresh_label_metrics() def get_value(self) -> str: return self.value @@ -256,7 +271,7 @@ class BigToggle(BigButton): self._checked = initial_state self._toggle_callback = toggle_callback - self._label.set_font_size(48) + self._set_label_font_size_override(48) def _load_images(self): super()._load_images() @@ -296,8 +311,8 @@ class BigMultiToggle(BigToggle): self._select_callback = select_callback self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)) - # TODO: why isn't this automatic? - self._label.set_font_size(self._get_label_font_size()) + # Keep the title size stable when the selected option changes. + self._set_label_font_size_override(self._get_label_font_size()) self.set_value(self._options[0]) diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py index 34760925..5f201766 100644 --- a/selfdrive/ui/mici/widgets/dialog.py +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -113,6 +113,14 @@ class BigConfirmationDialogV2(BigDialogBase): self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm) self._slider.set_enabled(lambda: not self._swiping_away) + def show_event(self): + super().show_event() + self._slider.show_event() + + def hide_event(self): + super().hide_event() + self._slider.hide_event() + def _on_confirm(self): if self._confirm_callback: self._confirm_callback() @@ -122,7 +130,7 @@ class BigConfirmationDialogV2(BigDialogBase): def _update_state(self): super()._update_state() if self._swiping_away and not self._slider.confirmed: - self._slider.reset() + self._slider.reset(reset_shimmer=False) def _render(self, _) -> DialogResult: self._slider.render(self._rect) diff --git a/selfdrive/ui/qt/offroad/settings.cc b/selfdrive/ui/qt/offroad/settings.cc index e54c6f6f..65568e71 100644 --- a/selfdrive/ui/qt/offroad/settings.cc +++ b/selfdrive/ui/qt/offroad/settings.cc @@ -363,6 +363,26 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) { connect(dcamBtn, &ButtonControl::clicked, [=]() { emit showDriverView(); }); addItem(dcamBtn); + resetDmCalibBtn = new ButtonControl( + tr("Reset Driver Monitoring"), + tr("RESET"), + tr("Clears the saved driver monitoring wheel-side calibration if the device thinks you're seated on the wrong side. " + "Resetting will restart openpilot if the car is powered on.") + ); + connect(resetDmCalibBtn, &ButtonControl::clicked, [&]() { + if (!uiState()->engaged()) { + if (ConfirmationDialog::confirm(tr("Are you sure you want to reset driver monitoring calibration?"), tr("Reset"), this)) { + if (!uiState()->engaged()) { + params.remove("IsRhdDetected"); + params.putBool("OnroadCycleRequested", true); + } + } + } else { + ConfirmationDialog::alert(tr("Disengage to Reset Driver Monitoring"), this); + } + }); + addItem(resetDmCalibBtn); + resetCalibBtn = new ButtonControl(tr("Reset Calibration"), tr("RESET"), ""); connect(resetCalibBtn, &ButtonControl::showDescriptionEvent, this, &DevicePanel::updateCalibDescription); connect(resetCalibBtn, &ButtonControl::clicked, [&]() { @@ -420,7 +440,7 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) { }); QObject::connect(uiState(), &UIState::offroadTransition, [=](bool offroad) { for (auto btn : findChildren()) { - if (btn != pair_device && btn != resetCalibBtn) { + if (btn != pair_device && btn != resetCalibBtn && btn != resetDmCalibBtn) { btn->setEnabled(offroad); } } diff --git a/selfdrive/ui/qt/offroad/settings.h b/selfdrive/ui/qt/offroad/settings.h index 438ee0eb..252b5062 100644 --- a/selfdrive/ui/qt/offroad/settings.h +++ b/selfdrive/ui/qt/offroad/settings.h @@ -94,6 +94,7 @@ private: ButtonControl *pair_galaxy; QPushButton *galaxy_qr_btn; ButtonControl *resetCalibBtn; + ButtonControl *resetDmCalibBtn; }; class TogglesPanel : public ListWidget { diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py index 3624fd77..079267b6 100644 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -133,11 +133,17 @@ class SoftwareSelectionPage(Widget): super().__init__() self._openpilot_slider = LargerSlider("slide to use\nstarpilot", use_openpilot_callback) - self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False) + self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, + green=False, shimmer_offset=0.4) + + def show_event(self): + super().show_event() + self._openpilot_slider.show_event() + self._custom_software_slider.show_event() def reset(self): - self._openpilot_slider.reset() - self._custom_software_slider.reset() + self._openpilot_slider.reset(reset_shimmer=False) + self._custom_software_slider.reset(reset_shimmer=False) def _render(self, rect: rl.Rectangle): self._openpilot_slider.set_opacity(1.0 - self._custom_software_slider.slider_percentage) diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 97b29308..7bfbd6dc 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -1,3 +1,4 @@ +import math from enum import IntEnum from collections.abc import Callable from itertools import zip_longest @@ -401,6 +402,12 @@ class UnifiedLabel(Widget): - Proper multiline vertical alignment - Height calculation for layout purposes """ + SHIMMER_BAND_WIDTH = 0.3 + SHIMMER_BLUR_RADIUS = 0.12 + SHIMMER_CYCLE_PERIOD = 2.5 + SHIMMER_SWEEP_FRACTION = 0.9 + SHIMMER_LOW_OPACITY = 0.65 + def __init__(self, text: str | Callable[[], str], font_size: int = DEFAULT_TEXT_SIZE, @@ -414,7 +421,8 @@ class UnifiedLabel(Widget): wrap_text: bool = True, scroll: bool = False, line_height: float = 1.0, - letter_spacing: float = 0.0): + letter_spacing: float = 0.0, + shimmer: bool = False): super().__init__() self._text = text self._font_size = font_size @@ -431,6 +439,8 @@ class UnifiedLabel(Widget): self._line_height = line_height * 0.9 self._letter_spacing = letter_spacing # 0.1 = 10% self._spacing_pixels = font_size * letter_spacing + self._shimmer = shimmer + self._shimmer_start_time = 0.0 # Scroll state self._scroll = scroll @@ -489,6 +499,13 @@ class UnifiedLabel(Widget): self._spacing_pixels = self._font_size * letter_spacing self._cached_text = None # Invalidate cache + def set_line_height(self, line_height: float): + """Update line height (multiplier, e.g., 1.0 = default).""" + new_line_height = line_height * 0.9 + if self._line_height != new_line_height: + self._line_height = new_line_height + self._cached_text = None + def set_font_weight(self, font_weight: FontWeight): """Update the font weight.""" if self._font_weight != font_weight: @@ -510,6 +527,9 @@ class UnifiedLabel(Widget): self._scroll_pause_t = None self._scroll_state = ScrollState.STARTING + def reset_shimmer(self, offset: float = 0.0): + self._shimmer_start_time = rl.get_time() + offset + def set_max_width(self, max_width: int | None): """Set the maximum width constraint for wrapping/eliding.""" if self._max_width != max_width: @@ -627,6 +647,25 @@ class UnifiedLabel(Widget): return self._cached_total_height return 0.0 + def _compute_shimmer_alpha(self, char_center_x: float, text_left: float, text_width: float) -> float: + if text_width <= 0: + return self.SHIMMER_LOW_OPACITY + + elapsed = rl.get_time() - self._shimmer_start_time + sigma = text_width * self.SHIMMER_BLUR_RADIUS + + t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD + t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0)) + t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) + + margin = text_width * self.SHIMMER_BAND_WIDTH + text_right = text_left + text_width + center = text_right + margin - t * (text_width + 2.0 * margin) + + d = char_center_x - center + shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) if sigma > 0 else 0.0 + return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer + def _render(self, _): """Render the label.""" if self._rect.width <= 0 or self._rect.height <= 0: @@ -770,6 +809,20 @@ class UnifiedLabel(Widget): line_x = self._rect.x + self._text_padding line_x += self._scroll_offset + x_offset + if self._shimmer and not emojis and line: + base_alpha = self._text_color.a / 255.0 + text_width = max(size.x, 1.0) + cursor_x = line_x + for char in line: + char_width = measure_text_cached(self._font, char, self._font_size, self._spacing_pixels).x + char_center_x = cursor_x + char_width / 2.0 + shimmer_alpha = self._compute_shimmer_alpha(char_center_x, line_x, text_width) + char_alpha = int(255 * base_alpha * shimmer_alpha) + char_color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, char_alpha) + rl.draw_text_ex(self._font, char, rl.Vector2(cursor_x, current_y), self._font_size, self._spacing_pixels, char_color) + cursor_x += char_width + return + # Render line with emojis line_pos = rl.Vector2(line_x, current_y) prev_index = 0 diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index f33ba941..e941486a 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -11,6 +11,7 @@ ITEM_SPACING = 20 LINE_COLOR = rl.GRAY LINE_PADDING = 40 ANIMATION_SCALE = 0.6 +EDGE_SHADOW_WIDTH = 20 MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds DO_ZOOM = False @@ -33,9 +34,50 @@ class LineSeparator(Widget): LINE_COLOR) +class ScrollIndicator(Widget): + def __init__(self): + super().__init__() + self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/horizontal_scroll_indicator.png", 96, 48) + self._scroll_offset: float = 0.0 + self._content_size: float = 0.0 + self._viewport: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) + + def update(self, scroll_offset: float, content_size: float, viewport: rl.Rectangle) -> None: + self._scroll_offset = scroll_offset + self._content_size = content_size + self._viewport = viewport + + def _render(self, _): + if self._viewport.width <= 0 or self._viewport.height <= 0: + return + + indicator_w = min(float(np.interp(self._content_size, [1000, 3000], [300, 100])), self._viewport.width) + max_scroll = self._content_size - self._viewport.width + if max_scroll > 0: + scroll_ratio = -self._scroll_offset / max_scroll + slide_range = max(self._viewport.width - indicator_w, 0.0) + x = self._viewport.x + scroll_ratio * slide_range + else: + x = self._viewport.x + (self._viewport.width - indicator_w) / 2 + y = max(self._viewport.y, 0) + self._viewport.height - self._txt_scroll_indicator.height / 2 + + dest_left = max(x, self._viewport.x) + dest_right = min(x + indicator_w, self._viewport.x + self._viewport.width) + dest_w = max(indicator_w / 2, dest_right - dest_left) + + dest_left = min(dest_left, self._viewport.x + self._viewport.width - dest_w) + dest_left = max(dest_left, self._viewport.x) + + src_rec = rl.Rectangle(0, 0, self._txt_scroll_indicator.width, self._txt_scroll_indicator.height) + dest_rec = rl.Rectangle(dest_left, y, dest_w, self._txt_scroll_indicator.height) + rl.draw_texture_pro(self._txt_scroll_indicator, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, + rl.Color(255, 255, 255, int(255 * 0.45))) + + class Scroller(Widget): def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING, - line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING): + line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING, + scroll_indicator: bool = False, edge_shadows: bool = False): super().__init__() self._items: list[Widget] = [] self._horizontal = horizontal @@ -65,11 +107,18 @@ class Scroller(Widget): self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items) self._scroll_enabled: bool | Callable[[], bool] = True - self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80) + self._txt_vertical_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80) + self._show_scroll_indicator = scroll_indicator and self._horizontal + self._scroll_indicator = ScrollIndicator() + self._edge_shadows = edge_shadows and self._horizontal for item in items: self.add_widget(item) + @property + def items(self) -> list[Widget]: + return self._items + def set_reset_scroll_at_show(self, scroll: bool): self._reset_scroll_at_show = scroll @@ -93,6 +142,14 @@ class Scroller(Widget): self._items.append(item) item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled) + def move_item(self, from_index: int, to_index: int) -> None: + if from_index == to_index: + return + if not (0 <= from_index < len(self._items) and 0 <= to_index < len(self._items)): + return + item = self._items.pop(from_index) + self._items.insert(to_index, item) + def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None: """Set whether scrolling is enabled (does not affect widget enabled state).""" self._scroll_enabled = enabled @@ -243,13 +300,27 @@ class Scroller(Widget): # Draw scroll indicator if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0: - _real_content_size = self._content_size - self._rect.height + self._txt_scroll_indicator.height + _real_content_size = self._content_size - self._rect.height + self._txt_vertical_scroll_indicator.height scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height - scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_scroll_indicator.height) - rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE) + scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_vertical_scroll_indicator.height) + rl.draw_texture_ex(self._txt_vertical_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE) rl.end_scissor_mode() + if self._edge_shadows: + rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), + EDGE_SHADOW_WIDTH, int(self._rect.height), + rl.Color(0, 0, 0, 166), rl.BLANK) + + right_x = int(self._rect.x + self._rect.width - EDGE_SHADOW_WIDTH) + rl.draw_rectangle_gradient_h(right_x, int(self._rect.y), + EDGE_SHADOW_WIDTH, int(self._rect.height), + rl.BLANK, rl.Color(0, 0, 0, 166)) + + if self._show_scroll_indicator and len(self._visible_items) > 0: + self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect) + self._scroll_indicator.render() + def show_event(self): super().show_event() if self._reset_scroll_at_show: diff --git a/system/ui/widgets/slider.py b/system/ui/widgets/slider.py index 455cdeef..e606c5c3 100644 --- a/system/ui/widgets/slider.py +++ b/system/ui/widgets/slider.py @@ -5,17 +5,19 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter class SmallSlider(Widget): HORIZONTAL_PADDING = 8 CONFIRM_DELAY = 0.2 + PRESSED_SCALE = 1.07 - def __init__(self, title: str, confirm_callback: Callable | None = None): + def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0): # TODO: unify this with BigConfirmationDialogV2 super().__init__() self._confirm_callback = confirm_callback + self._shimmer_offset = shimmer_offset self._font = gui_app.font(FontWeight.DISPLAY) @@ -30,29 +32,40 @@ class SmallSlider(Widget): self._start_x_circle = 0.0 self._scroll_x_circle = 0.0 self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + self._circle_scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._circle_press_time: float | None = None self._is_dragging_circle = False - self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.MEDIUM, text_color=rl.Color(255, 255, 255, int(255 * 0.65)), + self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9) + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True) def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100)) self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100) self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100) + self._circle_bg_pressed_txt = self._circle_bg_txt self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32) @property def confirmed(self) -> bool: return self._confirmed_time > 0.0 - def reset(self): + def show_event(self): + super().show_event() + self.reset() + + def reset(self, reset_shimmer: bool = True): # reset all slider state self._is_dragging_circle = False self._confirmed_time = 0.0 self._confirm_callback_called = False + self._circle_press_time = None + self._circle_scale_filter.x = 1.0 + if reset_shimmer: + self._label.reset_shimmer(self._shimmer_offset) def set_opacity(self, opacity: float, smooth: bool = False): if smooth: @@ -83,6 +96,7 @@ class SmallSlider(Widget): if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect): self._start_x_circle = mouse_event.pos.x self._is_dragging_circle = True + self._circle_press_time = rl.get_time() elif mouse_event.left_released: # swiped to left @@ -129,8 +143,9 @@ class SmallSlider(Widget): btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2 - if self._confirmed_time == 0.0 or self._scroll_x_circle > 0: - self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity_filter.x))) + label_alpha = int(255 * (1.0 - self.slider_percentage) * self._opacity_filter.x) + if label_alpha > 0: + self._label.set_text_color(rl.Color(255, 255, 255, label_alpha)) label_rect = rl.Rectangle( self._rect.x + 20, self._rect.y, @@ -139,18 +154,24 @@ class SmallSlider(Widget): ) self._label.render(label_rect) - # circle and arrow - rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white) + circle_pressed = self._is_dragging_circle or self.confirmed or ( + self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075 + ) + circle_bg_txt = self._circle_bg_pressed_txt if circle_pressed else self._circle_bg_txt + scale = self._circle_scale_filter.update(self.PRESSED_SCALE if circle_pressed else 1.0) + scaled_btn_x = btn_x + (self._circle_bg_txt.width * (1 - scale)) / 2 + scaled_btn_y = btn_y + (self._circle_bg_txt.height * (1 - scale)) / 2 + rl.draw_texture_ex(circle_bg_txt, rl.Vector2(scaled_btn_x, scaled_btn_y), 0.0, scale, white) - arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2 - arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2 + arrow_x = scaled_btn_x + (self._circle_bg_txt.width * scale - self._circle_arrow_txt.width) / 2 + arrow_y = scaled_btn_y + (self._circle_bg_txt.height * scale - self._circle_arrow_txt.height) / 2 rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white) class LargerSlider(SmallSlider): - def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True): + def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0): self._green = green - super().__init__(title, confirm_callback=confirm_callback) + super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset) def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115)) @@ -158,6 +179,7 @@ class LargerSlider(SmallSlider): self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115) circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle" self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115) + self._circle_bg_pressed_txt = self._circle_bg_txt self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55) @@ -165,15 +187,16 @@ class BigSlider(SmallSlider): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None): self._icon = icon super().__init__(title, confirm_callback=confirm_callback) - self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)), + self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - line_height=0.875) + line_height=0.875, shimmer=True) def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180)) self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180) self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) + self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180) self._circle_arrow_txt = self._icon @@ -183,4 +206,5 @@ class RedBigSlider(BigSlider): self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180) self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180) + self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180) self._circle_arrow_txt = self._icon