From 1bf0fb385149796b994186ac56b05f3868c67aa6 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 27 Feb 2026 02:37:02 -0800 Subject: [PATCH] mici ui: Scroller widget helpers (#37451) * it's so dumb * niceeee * oh this is interesting * this is actually epic * clean up * more clean up * cmt * super * forgot * top --- selfdrive/ui/mici/layouts/main.py | 19 +++-------- selfdrive/ui/mici/layouts/offroad_alerts.py | 13 ++------ .../ui/mici/layouts/settings/developer.py | 17 ++-------- selfdrive/ui/mici/layouts/settings/device.py | 18 +++-------- .../ui/mici/layouts/settings/firehose.py | 1 + .../mici/layouts/settings/network/__init__.py | 12 ++----- .../mici/layouts/settings/network/wifi_ui.py | 14 ++------ .../ui/mici/layouts/settings/settings.py | 20 ++---------- selfdrive/ui/mici/layouts/settings/toggles.py | 16 ++-------- system/ui/widgets/nav_widget.py | 16 +--------- system/ui/widgets/scroller.py | 32 ++++++++++++++++++- 11 files changed, 60 insertions(+), 118 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 3e3948eeab..e39a228daf 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -14,9 +14,9 @@ from openpilot.system.ui.lib.application import gui_app ONROAD_DELAY = 2.5 # seconds -class MiciMainLayout(Widget): +class MiciMainLayout(Scroller): def __init__(self): - super().__init__() + super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False) self._pm = messaging.PubMaster(['bookmarkButton']) @@ -36,13 +36,12 @@ class MiciMainLayout(Widget): # TODO: set parent rect and use it if never passed rect from render (like in Scroller) widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - self._scroller = Scroller([ + self._scroller.add_widgets([ self._alerts_layout, self._home_layout, self._onroad_layout, - ], snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False) + ]) self._scroller.set_reset_scroll_at_show(False) - self._scroller.set_enabled(lambda: self.enabled) # for nav stack # Disable scrolling when onroad is interacting with bookmark self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left()) @@ -62,14 +61,6 @@ class MiciMainLayout(Widget): self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) device.add_interactive_timeout_callback(self._on_interactive_timeout) - def show_event(self): - super().show_event() - self._scroller.show_event() - - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - def _scroll_to(self, layout: Widget): layout_x = int(layout.rect.x) self._scroller.scroll_to(layout_x, smooth=True) @@ -83,7 +74,7 @@ class MiciMainLayout(Widget): self._setup = True # Render - self._scroller.render(self._rect) + super()._render(self._rect) self._handle_transitions() diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py index bc1cd02c5d..5ccb815da6 100644 --- a/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -186,19 +186,17 @@ class AlertItem(Widget): rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE) -class MiciOffroadAlerts(Widget): +class MiciOffroadAlerts(Scroller): """Offroad alerts layout with vertical scrolling.""" def __init__(self): - super().__init__() + # Create vertical scroller + super().__init__(horizontal=False, spacing=12, pad=0) self.params = Params() self.sorted_alerts: list[AlertData] = [] self.alert_items: list[AlertItem] = [] self._last_refresh = 0.0 - # Create vertical scroller - self._scroller = Scroller([], horizontal=False, spacing=12, pad=0) - # Create empty state label self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, @@ -290,14 +288,9 @@ class MiciOffroadAlerts(Widget): def show_event(self): """Reset scroll position when shown and refresh alerts.""" super().show_event() - self._scroller.show_event() self._last_refresh = time.monotonic() self.refresh() - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - def _update_state(self): """Periodically refresh alerts.""" # Refresh alerts periodically, not every frame diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py index ee0856a20e..4d6bdfc3bb 100644 --- a/selfdrive/ui/mici/layouts/settings/developer.py +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -1,17 +1,14 @@ -import pyray as rl - from openpilot.common.time_helpers import system_time_valid -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl, BigCircleParamControl from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction -class DeveloperLayoutMici(NavWidget): +class DeveloperLayoutMici(NavScroller): def __init__(self): super().__init__() self.set_back_callback(gui_app.pop_widget) @@ -61,7 +58,7 @@ class DeveloperLayoutMici(NavWidget): toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked))) - self._scroller = Scroller([ + self._scroller.add_widgets([ self._adb_toggle, self._ssh_toggle, self._ssh_keys_btn, @@ -105,16 +102,8 @@ class DeveloperLayoutMici(NavWidget): def show_event(self): super().show_event() - self._scroller.show_event() self._update_toggles() - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) - def _update_toggles(self): ui_state.update_params() diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index cd7172455f..7383393542 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -7,7 +7,7 @@ from collections.abc import Callable from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2 @@ -32,6 +32,7 @@ class MiciFccModal(NavWidget): self.set_back_callback(gui_app.pop_widget) self._content = HtmlRenderer(file_path=file_path, text=text) self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._scroll_panel.set_enabled(lambda: self.enabled and not self._swiping_away) self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64) def _render(self, rect: rl.Rectangle): @@ -266,7 +267,7 @@ class UpdateOpenpilotBigButton(BigButton): self._waiting_for_updater_t = None -class DeviceLayoutMici(NavWidget): +class DeviceLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -313,7 +314,7 @@ class DeviceLayoutMici(NavWidget): review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(TrainingGuide(completed_callback=gui_app.pop_widget))) review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad()) - self._scroller = Scroller([ + self._scroller.add_widgets([ DeviceInfoLayoutMici(), UpdateOpenpilotBigButton(), PairBigButton(), @@ -340,14 +341,3 @@ class DeviceLayoutMici(NavWidget): def _offroad_transition(self): self._power_off_btn.set_visible(ui_state.is_offroad()) - - def show_event(self): - super().show_event() - self._scroller.show_event() - - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py index eb3331c868..b2c06d7299 100644 --- a/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -224,3 +224,4 @@ class FirehoseLayout(FirehoseLayoutBase, NavWidget): def __init__(self): super().__init__() self.set_back_callback(gui_app.pop_widget) + self._scroll_panel.set_enabled(lambda: self.enabled and not self._swiping_away) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index e5049a9ee7..ce3f1a817e 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,13 +1,12 @@ import pyray as rl -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici, WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType, ConnectStatus, SecurityType, normalize_ssid @@ -65,7 +64,7 @@ class WifiNetworkButton(BigButton): rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE) -class NetworkLayoutMici(NavWidget): +class NetworkLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -132,7 +131,7 @@ class NetworkLayoutMici(NavWidget): self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", toggle_callback=self._toggle_cellular_metered) # Main scroller ---------------------------------- - self._scroller = Scroller([ + self._scroller.add_widgets([ self._wifi_button, self._network_metered_btn, self._tethering_toggle_btn, @@ -165,14 +164,12 @@ class NetworkLayoutMici(NavWidget): def show_event(self): super().show_event() self._wifi_manager.set_active(True) - self._scroller.show_event() # Process wifi callbacks while at any point in the nav stack gui_app.set_nav_stack_tick(self._wifi_manager.process_callbacks) def hide_event(self): super().hide_event() - self._scroller.hide_event() self._wifi_manager.set_active(False) gui_app.set_nav_stack_tick(None) @@ -213,6 +210,3 @@ class NetworkLayoutMici(NavWidget): MeteredType.YES: 'metered', MeteredType.NO: 'unmetered' }.get(self._wifi_manager.current_network_metered, 'default')) - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index dda1d12220..2fbe23c191 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -9,8 +9,7 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfir from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid @@ -271,15 +270,13 @@ class ForgetButton(Widget): rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE) -class WifiUIMici(NavWidget): +class WifiUIMici(NavScroller): def __init__(self, wifi_manager: WifiManager): super().__init__() # Set up back navigation self.set_back_callback(gui_app.pop_widget) - self._scroller = Scroller([]) - self._loading_animation = LoadingAnimation() self._wifi_manager = wifi_manager @@ -294,17 +291,12 @@ class WifiUIMici(NavWidget): def show_event(self): # Clear scroller items and update from latest scan results super().show_event() - self._scroller.show_event() self._loading_animation.show_event() self._wifi_manager.set_active(True) self._scroller.items.clear() # trigger button update on latest sorted networks self._on_network_updated(self._wifi_manager.networks) - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} self._update_buttons() @@ -389,7 +381,7 @@ class WifiUIMici(NavWidget): self._loading_animation.show_event() def _render(self, _): - self._scroller.render(self._rect) + super()._render(self._rect) anim_w = 90 anim_x = self._rect.x + self._rect.width - anim_w diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 15fd681996..d996e01fed 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -1,7 +1,5 @@ -import pyray as rl - from openpilot.common.params import Params -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici @@ -9,7 +7,6 @@ from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.widgets.nav_widget import NavWidget class SettingsBigButton(BigButton): @@ -17,7 +14,7 @@ class SettingsBigButton(BigButton): return 64 -class SettingsLayout(NavWidget): +class SettingsLayout(NavScroller): def __init__(self): super().__init__() self._params = Params() @@ -42,7 +39,7 @@ class SettingsLayout(NavWidget): firehose_btn = SettingsBigButton("firehose", "", "icons_mici/settings/firehose.png", icon_size=(52, 62)) firehose_btn.set_click_callback(lambda: gui_app.push_widget(firehose_panel)) - self._scroller = Scroller([ + self._scroller.add_widgets([ toggles_btn, network_btn, device_btn, @@ -56,14 +53,3 @@ class SettingsLayout(NavWidget): self.set_back_callback(gui_app.pop_widget) self._font_medium = gui_app.font(FontWeight.MEDIUM) - - def show_event(self): - super().show_event() - self._scroller.show_event() - - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index d6a91b40f7..a7a7bff6e2 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,17 +1,15 @@ -import pyray as rl from cereal import log -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants -class TogglesLayoutMici(NavWidget): +class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() self.set_back_callback(gui_app.pop_widget) @@ -25,7 +23,7 @@ class TogglesLayoutMici(NavWidget): record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) - self._scroller = Scroller([ + self._scroller.add_widgets([ self._personality_toggle, self._experimental_btn, is_metric_toggle, @@ -68,13 +66,8 @@ class TogglesLayoutMici(NavWidget): def show_event(self): super().show_event() - self._scroller.show_event() self._update_toggles() - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - def _update_toggles(self): ui_state.update_params() @@ -93,6 +86,3 @@ class TogglesLayoutMici(NavWidget): # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: item.set_checked(ui_state.params.get_bool(key)) - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) diff --git a/system/ui/widgets/nav_widget.py b/system/ui/widgets/nav_widget.py index 2944f47a76..02afc911b2 100644 --- a/system/ui/widgets/nav_widget.py +++ b/system/ui/widgets/nav_widget.py @@ -69,8 +69,6 @@ class NavWidget(Widget, abc.ABC): self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - self._set_up = False - @property def back_enabled(self) -> bool: return self._back_enabled() if callable(self._back_enabled) else self._back_enabled @@ -96,6 +94,7 @@ class NavWidget(Widget, abc.ABC): self._pos_filter.update_alpha(0.04) in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE + # TODO: remove vertical scrolling and then this hacky logic to check if scroller is at top scroller_at_top = False vertical_scroller = False # TODO: -20? snapping in WiFi dialog can make offset not be positive at the top @@ -138,19 +137,6 @@ class NavWidget(Widget, abc.ABC): def _update_state(self): super()._update_state() - # Disable self's scroller while swiping away - if not self._set_up: - self._set_up = True - if hasattr(self, '_scroller'): - # TODO: use touch_valid - original_enabled = self._scroller._enabled - self._scroller.set_enabled(lambda: self.enabled and not self._swiping_away and (original_enabled() if callable(original_enabled) else - original_enabled)) - elif hasattr(self, '_scroll_panel'): - original_enabled = self._scroll_panel.enabled - self._scroll_panel.set_enabled(lambda: self.enabled and not self._swiping_away and (original_enabled() if callable(original_enabled) else - original_enabled)) - if self._trigger_animate_in: self._pos_filter.x = self._rect.height self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index 65378688e6..9d9f5663b8 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -7,6 +7,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget ITEM_SPACING = 20 LINE_COLOR = rl.GRAY @@ -66,7 +67,8 @@ class ScrollIndicator(Widget): rl.Color(255, 255, 255, int(255 * 0.45))) -class Scroller(Widget): +class _Scroller(Widget): + """Should use wrapper below to reduce boilerplate""" def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING, pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True): super().__init__() @@ -414,3 +416,31 @@ class Scroller(Widget): super().hide_event() for item in self._items: item.hide_event() + + +class Scroller(Widget): + """Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack.""" + def __init__(self, **kwargs): + super().__init__() + self._scroller = _Scroller([], **kwargs) + # pass down enabled to child widget for nav stack + self._scroller.set_enabled(lambda: self.enabled) + + def show_event(self): + super().show_event() + self._scroller.show_event() + + def hide_event(self): + super().hide_event() + self._scroller.hide_event() + + def _render(self, _): + self._scroller.render(self._rect) + + +class NavScroller(NavWidget, Scroller): + """Full screen Scroller that properly supports nav stack w/ animations""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + # pass down enabled to child widget for nav stack + disable while swiping away NavWidget + self._scroller.set_enabled(lambda: self.enabled and not self._swiping_away)