From 30350f4207b220b414b36b7ec4c9994050939b35 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 20 Feb 2026 19:00:27 -0800 Subject: [PATCH] ui: navigation stack (#37094) * initial * start to support nav stack in settings panels + fix some navwidget bugs * add deprecation warning and move more to new nav stack * fix overriding NavWidget enabled and do developer panel * fix interactive timeout and do main * more device, not done yet * minor network fixes * dcam dialog * start onboarding * fix onboarding * do mici setup * remove now useless CUSTOM_SOFTWARE * support big ui with old modal overlay * reset can be old modal overlay, but updater needs new since it uses wifiui * flip name truthiness to inspire excitement * all *should* work, but will do pass later * clean up main * clean up settiings * clean up dialog and developer * cleanup mici setup some * rm one more * fix keyboard * revert * might as well but clarify * fix networkinfopage buttons * lint * nice clean up from cursor * animate background fade with position * fix device overlays * cursor fix pt1 cursor fix pt2 * rm print * capital * temp fix from cursor for onboarding not freeing space after reviewing training guide * fix home screen scroller snap not resetting * stash * nice gradient on top * 40 * 20 * no gradient * return unused returns and always show regulatory btn * nice! * clean up * new_modal is always true! * more clean up * clean up * big only renders top 1 * fixup setup and updater * stash * Revert "stash" This reverts commit 3cfb226ccb51869ed1f7d630b5fdd6725ad094d5. * fix mici keys coming in from top * clean up * fix mici dialogs like tici, pop first incase call back pushes * clever way but not not * Revert "clever way but not not" This reverts commit f69d106df61262f049df20cc1a9064ca1e6feeb7. * more setup * mici keyboard: fix not disabling below * cmt * fix wifi callbacks not running in rare case * clean up network * clean up network * clean up dialog * pairing * rm * todo * fix replay * they push themselkves! * clean up ui_state * clean up application * clean up * stash * Revert "stash" This reverts commit 07d3f5f26c99ef891086b6fe03095d53a62b8631. * typing * lint --- selfdrive/ui/mici/layouts/main.py | 81 ++++------ selfdrive/ui/mici/layouts/onboarding.py | 14 +- .../ui/mici/layouts/settings/developer.py | 11 +- selfdrive/ui/mici/layouts/settings/device.py | 38 ++--- .../ui/mici/layouts/settings/firehose.py | 7 +- .../mici/layouts/settings/network/__init__.py | 42 ++---- .../mici/layouts/settings/network/wifi_ui.py | 45 ++---- .../ui/mici/layouts/settings/settings.py | 89 +++-------- selfdrive/ui/mici/layouts/settings/toggles.py | 5 +- .../ui/mici/onroad/augmented_road_view.py | 2 +- .../ui/mici/onroad/driver_camera_dialog.py | 10 +- selfdrive/ui/mici/tests/test_widget_leaks.py | 2 - selfdrive/ui/mici/widgets/dialog.py | 38 ++--- selfdrive/ui/mici/widgets/pairing_dialog.py | 11 +- selfdrive/ui/onroad/augmented_road_view.py | 2 +- selfdrive/ui/onroad/driver_camera_dialog.py | 2 +- selfdrive/ui/tests/diff/replay.py | 8 +- selfdrive/ui/tests/profile_onroad.py | 5 +- selfdrive/ui/ui.py | 12 +- selfdrive/ui/widgets/pairing_dialog.py | 2 +- system/ui/lib/application.py | 102 ++++--------- system/ui/mici_reset.py | 8 +- system/ui/mici_setup.py | 141 +++++++----------- system/ui/mici_updater.py | 7 +- system/ui/tici_reset.py | 2 +- system/ui/tici_setup.py | 2 +- system/ui/tici_updater.py | 2 +- system/ui/widgets/__init__.py | 31 +++- system/ui/widgets/keyboard.py | 2 +- system/ui/widgets/mici_keyboard.py | 7 +- system/ui/widgets/network.py | 2 +- 31 files changed, 260 insertions(+), 472 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index b78a1d8ea..be2c4747f 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,5 +1,4 @@ import pyray as rl -from enum import IntEnum import cereal.messaging as messaging from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout @@ -15,18 +14,12 @@ from openpilot.system.ui.lib.application import gui_app ONROAD_DELAY = 2.5 # seconds -class MainState(IntEnum): - MAIN = 0 - SETTINGS = 1 - - class MiciMainLayout(Widget): def __init__(self): super().__init__() self._pm = messaging.PubMaster(['bookmarkButton']) - self._current_mode: MainState | None = None self._prev_onroad = False self._prev_standstill = False self._onroad_time_delay: float | None = None @@ -49,38 +42,31 @@ class MiciMainLayout(Widget): self._onroad_layout, ], spacing=0, pad_start=0, pad_end=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()) - self._layouts = { - MainState.MAIN: self._scroller, - MainState.SETTINGS: self._settings_layout, - } - # Set callbacks self._setup_callbacks() - # Start onboarding if terms or training not completed + gui_app.push_widget(self) + + # Start onboarding if terms or training not completed, make sure to push after self self._onboarding_window = OnboardingWindow() if not self._onboarding_window.completed: - gui_app.set_modal_overlay(self._onboarding_window) + gui_app.push_widget(self._onboarding_window) def _setup_callbacks(self): - self._home_layout.set_callbacks(on_settings=self._on_settings_clicked) - self._settings_layout.set_callbacks(on_close=self._on_settings_closed) + self._home_layout.set_callbacks(on_settings=lambda: gui_app.push_widget(self._settings_layout)) self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) - device.add_interactive_timeout_callback(self._set_mode_for_started) + device.add_interactive_timeout_callback(self._on_interactive_timeout) def _scroll_to(self, layout: Widget): layout_x = int(layout.rect.x) self._scroller.scroll_to(layout_x, smooth=True) def _render(self, _): - # Initial show event - if self._current_mode is None: - self._set_mode(MainState.MAIN) - if not self._setup: if self._alerts_layout.active_alerts() > 0: self._scroller.scroll_to(self._alerts_layout.rect.x) @@ -89,59 +75,50 @@ class MiciMainLayout(Widget): self._setup = True # Render - if self._current_mode == MainState.MAIN: - self._scroller.render(self._rect) - - elif self._current_mode == MainState.SETTINGS: - self._settings_layout.render(self._rect) + self._scroller.render(self._rect) self._handle_transitions() - def _set_mode(self, mode: MainState): - if mode != self._current_mode: - if self._current_mode is not None: - self._layouts[self._current_mode].hide_event() - self._layouts[mode].show_event() - self._current_mode = mode - def _handle_transitions(self): + # Don't pop if onboarding + if gui_app.get_active_widget() == self._onboarding_window: + return + if ui_state.started != self._prev_onroad: self._prev_onroad = ui_state.started + # onroad: after delay, pop nav stack and scroll to onroad + # offroad: immediately scroll to home, but don't pop nav stack (can stay in settings) if ui_state.started: self._onroad_time_delay = rl.get_time() else: - self._set_mode_for_started(True) + self._scroll_to(self._home_layout) - # delay so we show home for a bit after starting if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: - self._set_mode_for_started(True) + gui_app.pop_widgets_to(self) + self._scroll_to(self._onroad_layout) self._onroad_time_delay = None + # When car leaves standstill, pop nav stack and scroll to onroad CS = ui_state.sm["carState"] if not CS.standstill and self._prev_standstill: - self._set_mode(MainState.MAIN) + gui_app.pop_widgets_to(self) self._scroll_to(self._onroad_layout) self._prev_standstill = CS.standstill - def _set_mode_for_started(self, onroad_transition: bool = False): + def _on_interactive_timeout(self): + # Don't pop if onboarding + if gui_app.get_active_widget() == self._onboarding_window: + return + if ui_state.started: - CS = ui_state.sm["carState"] - # Only go onroad if car starts or is not at a standstill - if not CS.standstill or onroad_transition: - self._set_mode(MainState.MAIN) + # Don't pop if at standstill + if not ui_state.sm["carState"].standstill: + gui_app.pop_widgets_to(self) self._scroll_to(self._onroad_layout) else: - # Stay in settings if car turns off while in settings - if not onroad_transition or self._current_mode != MainState.SETTINGS: - self._set_mode(MainState.MAIN) - self._scroll_to(self._home_layout) - - def _on_settings_clicked(self): - self._set_mode(MainState.SETTINGS) - - def _on_settings_closed(self): - self._set_mode(MainState.MAIN) + gui_app.pop_widgets_to(self) + self._scroll_to(self._home_layout) def _on_bookmark_clicked(self): user_bookmark = messaging.new_message('bookmarkButton') diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index a399d7679..539074453 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -7,7 +7,7 @@ import pyray as rl from openpilot.common.filter_simple import FirstOrderFilter from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets import Widget, NavWidget from openpilot.system.ui.widgets.button import SmallButton, SmallCircleIconButton from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.slider import SmallSlider @@ -42,7 +42,7 @@ class DriverCameraSetupDialog(DriverCameraDialog): gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) rl.end_scissor_mode() - return -1 + return # Position dmoji on opposite side from driver is_rhd = self.driver_state_renderer.is_rhd @@ -55,7 +55,6 @@ class DriverCameraSetupDialog(DriverCameraDialog): self._draw_face_detection(rect) rl.end_scissor_mode() - return -1 class TrainingGuidePreDMTutorial(SetupTermsPage): @@ -367,9 +366,9 @@ class TrainingGuide(Widget): self._completed_callback() def _render(self, _): + rl.draw_rectangle_rec(self._rect, rl.BLACK) if self._step < len(self._steps): self._steps[self._step].render(self._rect) - return -1 class DeclinePage(Widget): @@ -438,9 +437,11 @@ class TermsPage(SetupTermsPage): )) -class OnboardingWindow(Widget): +class OnboardingWindow(NavWidget): def __init__(self): super().__init__() + self.set_back_enabled(False) + self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version @@ -473,7 +474,7 @@ class OnboardingWindow(Widget): def close(self): ui_state.params.put_bool("IsDriverViewEnabled", False) - gui_app.set_modal_overlay(None) + gui_app.pop_widget() def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) @@ -490,4 +491,3 @@ class OnboardingWindow(Widget): self._training_guide.render(self._rect) elif self._state == OnboardingState.DECLINE: self._decline_page.render(self._rect) - return -1 diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py index ad68d6ee9..383c7ef9b 100644 --- a/selfdrive/ui/mici/layouts/settings/developer.py +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -1,5 +1,4 @@ import pyray as rl -from collections.abc import Callable from openpilot.common.time_helpers import system_time_valid from openpilot.system.ui.widgets.scroller import Scroller @@ -13,9 +12,9 @@ from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction class DeveloperLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) + self.set_back_callback(gui_app.pop_widget) def github_username_callback(username: str): if username: @@ -25,16 +24,16 @@ class DeveloperLayoutMici(NavWidget): self._ssh_keys_btn.set_value(username) else: dlg = BigDialog("", ssh_keys._error_message) - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) def ssh_keys_callback(): github_username = ui_state.params.get("GithubUsername") or "" dlg = BigInputDialog("enter GitHub username...", github_username, confirm_callback=github_username_callback) if not system_time_valid(): dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "") - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) return - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index 134626746..e1beae4fe 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -28,7 +28,7 @@ class MiciFccModal(NavWidget): def __init__(self, file_path: str | None = None, text: str | None = None): super().__init__() - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + self.set_back_callback(gui_app.pop_widget) self._content = HtmlRenderer(file_path=file_path, text=text) self._scroll_panel = GuiScrollPanel2(horizontal=False) self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64) @@ -47,8 +47,6 @@ class MiciFccModal(NavWidget): rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE) - return -1 - def _engaged_confirmation_callback(callback: Callable, action_text: str): if not ui_state.engaged: @@ -74,10 +72,10 @@ def _engaged_confirmation_callback(callback: Callable, action_text: str): dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red, exit_on_confirm=action_text == "reset", confirm_callback=confirm_callback) - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) else: dlg = BigDialog(f"Disengage to {action_text}", "") - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) class DeviceInfoLayoutMici(Widget): @@ -147,7 +145,7 @@ class PairBigButton(BigButton): dlg = BigDialog(tr("Device must be registered with the comma.ai backend to pair"), "") else: dlg = PairingDialog() - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond @@ -173,7 +171,7 @@ class UpdateOpenpilotBigButton(BigButton): def _handle_mouse_release(self, mouse_pos: MousePos): if not system_time_valid(): dlg = BigDialog(tr("Please connect to Wi-Fi to update"), "") - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) return self.set_enabled(False) @@ -268,12 +266,10 @@ class UpdateOpenpilotBigButton(BigButton): class DeviceLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): + def __init__(self): super().__init__() self._fcc_dialog: HtmlModal | None = None - self._driver_camera: DriverCameraDialog | None = None - self._training_guide: TrainingGuide | None = None def power_off_callback(): ui_state.params.put_bool("DoShutdown", True) @@ -309,11 +305,11 @@ class DeviceLayoutMici(NavWidget): regulatory_btn.set_click_callback(self._on_regulatory) driver_cam_btn = BigButton("driver\ncamera preview", "", "icons_mici/settings/device/cameras.png") - driver_cam_btn.set_click_callback(self._show_driver_camera) + driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog())) driver_cam_btn.set_enabled(lambda: ui_state.is_offroad()) review_training_guide_btn = BigButton("review\ntraining guide", "", "icons_mici/settings/device/info.png") - review_training_guide_btn.set_click_callback(self._on_review_training_guide) + 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([ @@ -330,7 +326,8 @@ class DeviceLayoutMici(NavWidget): ], snap_items=False) # Set up back navigation - self.set_back_callback(back_callback) + # TODO: can this somehow be generic in widgets/__init__.py or application.py? + self.set_back_callback(gui_app.pop_widget) # Hide power off button when onroad ui_state.add_offroad_transition_callback(self._offroad_transition) @@ -338,24 +335,11 @@ class DeviceLayoutMici(NavWidget): def _on_regulatory(self): if not self._fcc_dialog: self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/mici_fcc.html")) - gui_app.set_modal_overlay(self._fcc_dialog) + gui_app.push_widget(self._fcc_dialog) def _offroad_transition(self): self._power_off_btn.set_visible(ui_state.is_offroad()) - def _show_driver_camera(self): - if not self._driver_camera: - self._driver_camera = DriverCameraDialog() - gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None)) - - def _on_review_training_guide(self): - if not self._training_guide: - def completed_callback(): - gui_app.set_modal_overlay(None) - - self._training_guide = TrainingGuide(completed_callback=completed_callback) - gui_app.set_modal_overlay(self._training_guide, callback=lambda result: setattr(self, '_training_guide', None)) - def show_event(self): super().show_event() self._scroller.show_event() diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py index d305906e1..a2288752e 100644 --- a/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -132,9 +132,6 @@ class FirehoseLayoutBase(Widget): y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY) y += 20 - # return value not used by NavWidget - return -1 - def _draw_wrapped_text(self, x, y, width, text, font, font_size, color): wrapped = wrap_text(font, text, font_size, width) for line in wrapped: @@ -223,6 +220,6 @@ class FirehoseLayoutBase(Widget): class FirehoseLayout(FirehoseLayoutBase, NavWidget): BACK_TOUCH_AREA_PERCENTAGE = 0.1 - def __init__(self, back_callback): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) + self.set_back_callback(gui_app.pop_widget) diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index cd0f4ee80..9f49521c3 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,6 +1,4 @@ import pyray as rl -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, WifiIcon @@ -13,21 +11,13 @@ from openpilot.system.ui.widgets import NavWidget from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType, ConnectStatus, normalize_ssid -class NetworkPanelType(IntEnum): - NONE = 0 - WIFI = 1 - - class NetworkLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): + def __init__(self): super().__init__() - self._current_panel = NetworkPanelType.WIFI - self.set_back_enabled(lambda: self._current_panel == NetworkPanelType.NONE) - self._wifi_manager = WifiManager() self._wifi_manager.set_active(False) - self._wifi_ui = WifiUIMici(self._wifi_manager, back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE)) + self._wifi_ui = WifiUIMici(self._wifi_manager) self._wifi_manager.add_callbacks( networks_updated=self._on_network_updated, @@ -52,7 +42,7 @@ class NetworkLayoutMici(NavWidget): tethering_password = self._wifi_manager.tethering_password dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8, confirm_callback=tethering_password_callback) - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) txt_tethering = gui_app.texture("icons_mici/settings/network/tethering.png", 64, 54) self._tethering_password_btn = BigButton("tethering password", "", txt_tethering) @@ -79,7 +69,7 @@ class NetworkLayoutMici(NavWidget): self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) self._wifi_button = BigButton("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) - self._wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI)) + self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) # ******** Advanced settings ******** # ******** Roaming toggle ******** @@ -111,7 +101,7 @@ class NetworkLayoutMici(NavWidget): self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered) # Set up back navigation - self.set_back_callback(back_callback) + self.set_back_callback(gui_app.pop_widget) def _update_state(self): super()._update_state() @@ -146,14 +136,18 @@ class NetworkLayoutMici(NavWidget): def show_event(self): super().show_event() - self._current_panel = NetworkPanelType.NONE 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._wifi_manager.set_active(False) + gui_app.set_nav_stack_tick(None) + def _toggle_roaming(self, checked: bool): self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered")) @@ -169,7 +163,7 @@ class NetworkLayoutMici(NavWidget): current_apn = ui_state.params.get("GsmApn") or "" dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn) - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) def _toggle_cellular_metered(self, checked: bool): self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked) @@ -191,17 +185,5 @@ class NetworkLayoutMici(NavWidget): MeteredType.NO: 'unmetered' }.get(self._wifi_manager.current_network_metered, 'default')) - def _switch_to_panel(self, panel_type: NetworkPanelType): - if panel_type == NetworkPanelType.WIFI: - self._wifi_ui.show_event() - elif self._current_panel == NetworkPanelType.WIFI: - self._wifi_ui.hide_event() - self._current_panel = panel_type - def _render(self, rect: rl.Rectangle): - self._wifi_manager.process_callbacks() - - if self._current_panel == NetworkPanelType.WIFI: - self._wifi_ui.render(rect) - else: - self._scroller.render(rect) + 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 34e73e823..191836252 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -186,10 +186,9 @@ class ConnectButton(Widget): class ForgetButton(Widget): HORIZONTAL_MARGIN = 8 - def __init__(self, forget_network: Callable, open_network_manage_page): + def __init__(self, forget_network: Callable): super().__init__() self._forget_network = forget_network - self._open_network_manage_page = open_network_manage_page self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 100, 100) self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 100, 100) @@ -200,7 +199,7 @@ class ForgetButton(Widget): super()._handle_mouse_release(mouse_pos) dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True, confirm_callback=self._forget_network) - gui_app.set_modal_overlay(dlg, callback=self._open_network_manage_page) + gui_app.push_widget(dlg) def _render(self, _): bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt @@ -212,7 +211,7 @@ class ForgetButton(Widget): class NetworkInfoPage(NavWidget): - def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, open_network_manage_page: Callable, + def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, connecting_callback: Callable[[], str | None], connected_callback: Callable[[], str | None]): super().__init__() self._wifi_manager = wifi_manager @@ -220,8 +219,8 @@ class NetworkInfoPage(NavWidget): self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) self._wifi_icon = WifiIcon() - self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None, - open_network_manage_page) + self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None) + self._forget_btn.set_enabled(lambda: self.enabled) # for stack self._connect_btn = ConnectButton() self._connect_btn.set_click_callback(lambda: connect_callback(self._network.ssid) if self._network is not None else None) @@ -230,7 +229,7 @@ class NetworkInfoPage(NavWidget): self._subtitle = UnifiedLabel("", 36, FontWeight.ROMAN, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + self.set_back_callback(gui_app.pop_widget) # State self._network: Network | None = None @@ -249,11 +248,13 @@ class NetworkInfoPage(NavWidget): break else: # network disappeared, close page - gui_app.set_modal_overlay(None) + # TODO: pop_widgets_to, to close potentially open keyboard too + if gui_app.get_active_widget() == self: + gui_app.pop_widget() def _update_state(self): super()._update_state() - # Modal overlays stop main UI rendering, so we need to call here + # TODO: remove? only left for potential compatibility with setup/updater self._wifi_manager.process_callbacks() if self._network is None: @@ -271,7 +272,7 @@ class NetworkInfoPage(NavWidget): self._connect_btn.set_enabled(False) else: # saved or unknown self._connect_btn.set_label("connect") - self._connect_btn.set_enabled(True) + self._connect_btn.set_enabled(self.enabled) self._title.set_text(normalize_ssid(self._network.ssid)) if self._network.security_type == SecurityType.OPEN: @@ -336,17 +337,15 @@ class NetworkInfoPage(NavWidget): self._forget_btn.rect.height, )) - return -1 - class WifiUIMici(BigMultiOptionDialog): - def __init__(self, wifi_manager: WifiManager, back_callback: Callable): + def __init__(self, wifi_manager: WifiManager): super().__init__([], None) # Set up back navigation - self.set_back_callback(back_callback) + self.set_back_callback(gui_app.pop_widget) - self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, wifi_manager.forget_connection, self._open_network_manage_page, + self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, wifi_manager.forget_connection, lambda: wifi_manager.connecting_to_ssid, lambda: wifi_manager.connected_ssid) self._loading_animation = LoadingAnimation() @@ -370,11 +369,6 @@ class WifiUIMici(BigMultiOptionDialog): super().hide_event() self._scroller.hide_event() - def _open_network_manage_page(self, result=None): - if self._network_info_page._network is not None and self._network_info_page._network.ssid in self._networks: - self._network_info_page.update_networks(self._networks) - gui_app.set_modal_overlay(self._network_info_page) - def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} self._update_buttons() @@ -413,7 +407,7 @@ class WifiUIMici(BigMultiOptionDialog): if option in self._networks: self._network_info_page.set_current_network(self._networks[option]) - self._open_network_manage_page() + gui_app.push_widget(self._network_info_page) def _connect_to_network(self, ssid: str): network = self._networks.get(ssid) @@ -434,14 +428,7 @@ class WifiUIMici(BigMultiOptionDialog): hint = "wrong password..." if incorrect_password else "enter password..." dlg = BigInputDialog(hint, "", minimum_length=8, confirm_callback=lambda _password: self._connect_with_password(ssid, _password)) - - def on_close(result=None): - gui_app.set_modal_overlay_tick(None) - self._open_network_manage_page(result) - - # Process wifi callbacks while the keyboard is shown so forgotten clears connecting state - gui_app.set_modal_overlay_tick(self._wifi_manager.process_callbacks) - gui_app.set_modal_overlay(dlg, on_close) + gui_app.push_widget(dlg) def _render(self, _): super()._render(_) diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index a6f59a038..2372104a0 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -1,7 +1,4 @@ import pyray as rl -from dataclasses import dataclass -from enum import IntEnum -from collections.abc import Callable from openpilot.common.params import Params from openpilot.system.ui.widgets.scroller import Scroller @@ -12,22 +9,7 @@ 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 import Widget, NavWidget - - -class PanelType(IntEnum): - TOGGLES = 0 - NETWORK = 1 - DEVICE = 2 - DEVELOPER = 3 - USER_MANUAL = 4 - FIREHOSE = 5 - - -@dataclass -class PanelInfo: - name: str - instance: Widget +from openpilot.system.ui.widgets import NavWidget class SettingsBigButton(BigButton): @@ -39,19 +21,26 @@ class SettingsLayout(NavWidget): def __init__(self): super().__init__() self._params = Params() - self._current_panel = None # PanelType.DEVICE + toggles_panel = TogglesLayoutMici() toggles_btn = SettingsBigButton("toggles", "", "icons_mici/settings.png") - toggles_btn.set_click_callback(lambda: self._set_current_panel(PanelType.TOGGLES)) - network_btn = SettingsBigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png", icon_size=(76, 56)) - network_btn.set_click_callback(lambda: self._set_current_panel(PanelType.NETWORK)) - device_btn = SettingsBigButton("device", "", "icons_mici/settings/device_icon.png", icon_size=(74, 60)) - device_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVICE)) - developer_btn = SettingsBigButton("developer", "", "icons_mici/settings/developer_icon.png", icon_size=(64, 60)) - developer_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVELOPER)) + toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel)) + network_panel = NetworkLayoutMici() + network_btn = SettingsBigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png", icon_size=(76, 56)) + network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel)) + + device_panel = DeviceLayoutMici() + device_btn = SettingsBigButton("device", "", "icons_mici/settings/device_icon.png", icon_size=(74, 60)) + device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel)) + + developer_panel = DeveloperLayoutMici() + developer_btn = SettingsBigButton("developer", "", "icons_mici/settings/developer_icon.png", icon_size=(64, 60)) + developer_btn.set_click_callback(lambda: gui_app.push_widget(developer_panel)) + + firehose_panel = FirehoseLayout() firehose_btn = SettingsBigButton("firehose", "", "icons_mici/settings/firehose.png", icon_size=(52, 62)) - firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE)) + firehose_btn.set_click_callback(lambda: gui_app.push_widget(firehose_panel)) self._scroller = Scroller([ toggles_btn, @@ -64,55 +53,17 @@ class SettingsLayout(NavWidget): ], snap_items=False) # Set up back navigation - self.set_back_callback(self.close_settings) - self.set_back_enabled(lambda: self._current_panel is None) - - self._panels = { - PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.NETWORK: PanelInfo("Network", NetworkLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(back_callback=lambda: self._set_current_panel(None))), - } + self.set_back_callback(gui_app.pop_widget) self._font_medium = gui_app.font(FontWeight.MEDIUM) - # Callbacks - self._close_callback: Callable | None = None - def show_event(self): super().show_event() - self._set_current_panel(None) self._scroller.show_event() - if self._current_panel is not None: - self._panels[self._current_panel].instance.show_event() def hide_event(self): super().hide_event() - if self._current_panel is not None: - self._panels[self._current_panel].instance.hide_event() - - def set_callbacks(self, on_close: Callable): - self._close_callback = on_close + self._scroller.hide_event() def _render(self, rect: rl.Rectangle): - if self._current_panel is not None: - self._draw_current_panel() - else: - self._scroller.render(rect) - - def _draw_current_panel(self): - panel = self._panels[self._current_panel] - panel.instance.render(self._rect) - - def _set_current_panel(self, panel_type: PanelType | None): - if panel_type != self._current_panel: - if self._current_panel is not None: - self._panels[self._current_panel].instance.hide_event() - self._current_panel = panel_type - if self._current_panel is not None: - self._panels[self._current_panel].instance.show_event() - - def close_settings(self): - if self._close_callback: - self._close_callback() + self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index c16504fac..d6fb75a4d 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,5 +1,4 @@ import pyray as rl -from collections.abc import Callable from cereal import log from openpilot.system.ui.widgets.scroller import Scroller @@ -13,9 +12,9 @@ PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants class TogglesLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) + self.set_back_callback(gui_app.pop_widget) self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode") diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 69bcca401..99e33e864 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -363,7 +363,7 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(ROAD_CAM) + road_camera_view = AugmentedRoadView(lambda: None, stream_type=ROAD_CAM) print("***press space to switch camera view***") try: for _ in gui_app.render(): diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py index bab3d6e6f..26a5d132c 100644 --- a/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py @@ -34,8 +34,8 @@ class DriverCameraDialog(NavWidget): self._pm: messaging.PubMaster | None = None if not no_escape: # TODO: this can grow unbounded, should be given some thought - device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None)) - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + device.add_interactive_timeout_callback(gui_app.pop_widget) + self.set_back_callback(gui_app.pop_widget) self.set_back_enabled(not no_escape) # Load eye icons @@ -87,7 +87,7 @@ class DriverCameraDialog(NavWidget): alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) rl.end_scissor_mode() self._publish_alert_sound(None) - return -1 + return driver_data = self._draw_face_detection(rect) if driver_data is not None: @@ -105,7 +105,7 @@ class DriverCameraDialog(NavWidget): self._render_dm_alerts(rect) rl.end_scissor_mode() - return -1 + return def _publish_alert_sound(self, dm_state): """Publish selfdriveState with only alertSound field set""" @@ -235,9 +235,9 @@ if __name__ == "__main__": gui_app.init_window("Driver Camera View (mici)") driver_camera_view = DriverCameraDialog() + gui_app.push_widget(driver_camera_view) try: for _ in gui_app.render(): ui_state.update() - driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: driver_camera_view.close() diff --git a/selfdrive/ui/mici/tests/test_widget_leaks.py b/selfdrive/ui/mici/tests/test_widget_leaks.py index 12fa608b3..c7f21bb2f 100755 --- a/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -37,14 +37,12 @@ KNOWN_LEAKS = { "openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", "openpilot.system.ui.widgets.label.Label", "openpilot.system.ui.widgets.button.Button", - "openpilot.selfdrive.ui.mici.widgets.dialog.BigDialog", "openpilot.system.ui.widgets.html_render.HtmlRenderer", "openpilot.system.ui.widgets.NavBar", "openpilot.system.ui.widgets.inputbox.InputBox", "openpilot.system.ui.widgets.scroller_tici.Scroller", "openpilot.system.ui.widgets.scroller.Scroller", "openpilot.system.ui.widgets.label.UnifiedLabel", - "openpilot.selfdrive.ui.mici.widgets.dialog.BigMultiOptionDialog", "openpilot.system.ui.widgets.mici_keyboard.MiciKeyboard", "openpilot.selfdrive.ui.mici.widgets.dialog.BigConfirmationDialogV2", "openpilot.system.ui.widgets.keyboard.Keyboard", diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py index 6b4cb92c1..612f31b75 100644 --- a/selfdrive/ui/mici/widgets/dialog.py +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -4,7 +4,7 @@ import pyray as rl from typing import Union from collections.abc import Callable from typing import cast -from openpilot.system.ui.widgets import Widget, NavWidget, DialogResult +from openpilot.system.ui.widgets import Widget, NavWidget from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -23,17 +23,8 @@ PADDING = 20 class BigDialogBase(NavWidget, abc.ABC): def __init__(self): super().__init__() - self._ret = DialogResult.NO_ACTION self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - self.set_back_callback(lambda: setattr(self, '_ret', DialogResult.CANCEL)) - - def _render(self, _) -> DialogResult: - """ - Allows `gui_app.set_modal_overlay(BigDialog(...))`. - The overlay runner keeps calling until result != NO_ACTION. - """ - - return self._ret + self.set_back_callback(gui_app.pop_widget) class BigDialog(BigDialogBase): @@ -44,7 +35,7 @@ class BigDialog(BigDialogBase): self._title = title self._description = description - def _render(self, _) -> DialogResult: + def _render(self, _): super()._render(_) # draw title @@ -74,8 +65,6 @@ class BigDialog(BigDialogBase): gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) - return self._ret - class BigConfirmationDialogV2(BigDialogBase): def __init__(self, title: str, icon: str, red: bool = False, @@ -91,22 +80,21 @@ class BigConfirmationDialogV2(BigDialogBase): self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm) else: self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm) - self._slider.set_enabled(lambda: not self._swiping_away) + self._slider.set_enabled(lambda: self.enabled and not self._swiping_away) # self.enabled for nav stack def _on_confirm(self): + if self._exit_on_confirm: + gui_app.pop_widget() if self._confirm_callback: self._confirm_callback() - if self._exit_on_confirm: - self._ret = DialogResult.CONFIRM def _update_state(self): super()._update_state() if self._swiping_away and not self._slider.confirmed: self._slider.reset() - def _render(self, _) -> DialogResult: + def _render(self, _): self._slider.render(self._rect) - return self._ret class BigInputDialog(BigDialogBase): @@ -124,6 +112,7 @@ class BigInputDialog(BigDialogBase): font_weight=FontWeight.MEDIUM) self._keyboard = MiciKeyboard() self._keyboard.set_text(default_text) + self._keyboard.set_enabled(lambda: self.enabled) # for nav stack self._minimum_length = minimum_length self._backspace_held_time: float | None = None @@ -140,9 +129,10 @@ class BigInputDialog(BigDialogBase): self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0) def confirm_callback_wrapper(): - self._ret = DialogResult.CONFIRM + text = self._keyboard.text() + gui_app.pop_widget() if confirm_callback: - confirm_callback(self._keyboard.text()) + confirm_callback(text) self._confirm_callback = confirm_callback_wrapper def _update_state(self): @@ -238,8 +228,6 @@ class BigInputDialog(BigDialogBase): rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255)) rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255)) - return self._ret - def _handle_mouse_press(self, mouse_pos: MousePos): super()._handle_mouse_press(mouse_pos) # TODO: need to track where press was so enter and back can activate on release rather than press @@ -392,8 +380,6 @@ class BigMultiOptionDialog(BigDialogBase): super()._render(_) self._scroller.render(self._rect) - return self._ret - class BigDialogButton(BigButton): def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""): @@ -404,4 +390,4 @@ class BigDialogButton(BigButton): super()._handle_mouse_release(mouse_pos) dlg = BigDialog(self.text, self._description) - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) diff --git a/selfdrive/ui/mici/widgets/pairing_dialog.py b/selfdrive/ui/mici/widgets/pairing_dialog.py index 88bab2d00..088d2a0b6 100644 --- a/selfdrive/ui/mici/widgets/pairing_dialog.py +++ b/selfdrive/ui/mici/widgets/pairing_dialog.py @@ -19,7 +19,7 @@ class PairingDialog(NavWidget): def __init__(self): super().__init__() - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + self.set_back_callback(gui_app.pop_widget) self._params = Params() self._qr_texture: rl.Texture | None = None self._last_qr_generation = float("-inf") @@ -72,7 +72,7 @@ class PairingDialog(NavWidget): if ui_state.prime_state.is_paired(): self._playing_dismiss_animation = True - def _render(self, rect: rl.Rectangle) -> int: + def _render(self, rect: rl.Rectangle): self._check_qr_refresh() self._render_qr_code() @@ -85,8 +85,6 @@ class PairingDialog(NavWidget): rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) - return -1 - def _render_qr_code(self) -> None: if not self._qr_texture: error_font = gui_app.font(FontWeight.BOLD) @@ -107,10 +105,9 @@ class PairingDialog(NavWidget): if __name__ == "__main__": gui_app.init_window("pairing device") pairing = PairingDialog() + gui_app.push_widget(pairing) try: for _ in gui_app.render(): - result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - if result != -1: - break + pass finally: del pairing diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index f8fb589b6..17d89fbd5 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -219,7 +219,7 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": - gui_app.init_window("OnRoad Camera View", new_modal=True) + gui_app.init_window("OnRoad Camera View") road_camera_view = AugmentedRoadView(ROAD_CAM) gui_app.push_widget(road_camera_view) print("***press space to switch camera view***") diff --git a/selfdrive/ui/onroad/driver_camera_dialog.py b/selfdrive/ui/onroad/driver_camera_dialog.py index a4518a252..e66e04b82 100644 --- a/selfdrive/ui/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/onroad/driver_camera_dialog.py @@ -100,7 +100,7 @@ class DriverCameraDialog(CameraView): if __name__ == "__main__": - gui_app.init_window("Driver Camera View", new_modal=True) + gui_app.init_window("Driver Camera View") driver_camera_view = DriverCameraDialog() gui_app.push_widget(driver_camera_view) diff --git a/selfdrive/ui/tests/diff/replay.py b/selfdrive/ui/tests/diff/replay.py index 62a808209..7ed7ce936 100755 --- a/selfdrive/ui/tests/diff/replay.py +++ b/selfdrive/ui/tests/diff/replay.py @@ -38,7 +38,7 @@ def run_replay(variant: LayoutVariant) -> None: from openpilot.system.ui.lib.application import gui_app # Import here for accurate coverage from openpilot.selfdrive.ui.tests.diff.replay_script import build_script - gui_app.init_window("ui diff test", fps=FPS, new_modal=variant == "tizi") + gui_app.init_window("ui diff test", fps=FPS) # Dynamically import main layout based on variant if variant == "mici": @@ -46,7 +46,6 @@ def run_replay(variant: LayoutVariant) -> None: else: from openpilot.selfdrive.ui.layouts.main import MainLayout main_layout = MainLayout() - main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) pm = PubMaster(["deviceState", "pandaStates", "driverStateV2", "selfdriveState"]) script = build_script(pm, main_layout, variant) @@ -59,7 +58,7 @@ def run_replay(variant: LayoutVariant) -> None: rl.get_time = lambda: frame / FPS # Main loop to replay events and render frames - for should_render in gui_app.render(): + for _ in gui_app.render(): # Handle all events for the current frame while script_index < len(script) and script[script_index][0] == frame: _, event = script[script_index] @@ -82,9 +81,6 @@ def run_replay(variant: LayoutVariant) -> None: ui_state.update() - if should_render: - main_layout.render() - frame += 1 if script_index >= len(script): diff --git a/selfdrive/ui/tests/profile_onroad.py b/selfdrive/ui/tests/profile_onroad.py index fde4f25ff..18194d736 100755 --- a/selfdrive/ui/tests/profile_onroad.py +++ b/selfdrive/ui/tests/profile_onroad.py @@ -83,7 +83,6 @@ if __name__ == "__main__": gui_app.init_window("UI Profiling", fps=600) main_layout = MiciMainLayout() - main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) print("Running...") patch_submaster(message_chunks) @@ -95,15 +94,13 @@ if __name__ == "__main__": yuv_buffer_size = W * H + (W // 2) * (H // 2) * 2 yuv_data = np.random.randint(0, 256, yuv_buffer_size, dtype=np.uint8).tobytes() with cProfile.Profile() as pr: - for should_render in gui_app.render(): + for _ in gui_app.render(): if ui_state.sm.frame >= len(message_chunks): break if ui_state.sm.frame % 3 == 0: eof = int((ui_state.sm.frame % 3) * 0.05 * 1e9) vipc.send(VisionStreamType.VISION_STREAM_ROAD, yuv_data, ui_state.sm.frame % 3, eof, eof) ui_state.update() - if should_render: - main_layout.render() pr.dump_stats(f'{args.output}_deterministic.stats') rl.close_window() diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py index 0e271c72d..e3cac2618 100755 --- a/selfdrive/ui/ui.py +++ b/selfdrive/ui/ui.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import os -import pyray as rl from openpilot.system.hardware import TICI from openpilot.common.realtime import config_realtime_process, set_core_affinity @@ -16,20 +15,15 @@ def main(): cores = {5, } config_realtime_process(0, 51) + gui_app.init_window("UI") if BIG_UI: - gui_app.init_window("UI", new_modal=True) - main_layout = MainLayout() + MainLayout() else: - gui_app.init_window("UI") - main_layout = MiciMainLayout() - main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + MiciMainLayout() for should_render in gui_app.render(): ui_state.update() if should_render: - if not BIG_UI: - main_layout.render() - # reaffine after power save offlines our core if TICI and os.sched_getaffinity(0) != cores: try: diff --git a/selfdrive/ui/widgets/pairing_dialog.py b/selfdrive/ui/widgets/pairing_dialog.py index c07b2463f..1ff550e4b 100644 --- a/selfdrive/ui/widgets/pairing_dialog.py +++ b/selfdrive/ui/widgets/pairing_dialog.py @@ -160,7 +160,7 @@ class PairingDialog(Widget): if __name__ == "__main__": - gui_app.init_window("pairing device", new_modal=True) + gui_app.init_window("pairing device") pairing = PairingDialog() gui_app.push_widget(pairing) try: diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index f056fddbf..7c0bd2bf3 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -12,7 +12,6 @@ import subprocess from contextlib import contextmanager from collections.abc import Callable from collections import deque -from dataclasses import dataclass from enum import StrEnum from pathlib import Path from typing import NamedTuple @@ -115,12 +114,6 @@ def font_fallback(font: rl.Font) -> rl.Font: return font -@dataclass -class ModalOverlay: - overlay: object = None - callback: Callable | None = None - - class MousePos(NamedTuple): x: float y: float @@ -226,13 +219,9 @@ class GuiApplication: self._last_fps_log_time: float = time.monotonic() self._frame = 0 self._window_close_requested = False - self._modal_overlay = ModalOverlay() - self._modal_overlay_shown = False - self._modal_overlay_tick: Callable[[], None] | None = None - - # TODO: move over the entire ui and deprecate - self._new_modal = False self._nav_stack: list[object] = [] + self._nav_stack_tick: Callable[[], None] | None = None + self._nav_stack_widgets_to_render = 1 if self.big_ui() else 2 self._mouse = MouseState(self._scale) self._mouse_events: list[MouseEvent] = [] @@ -266,7 +255,7 @@ class GuiApplication: def request_close(self): self._window_close_requested = True - def init_window(self, title: str, fps: int = _DEFAULT_FPS, new_modal: bool = False): + def init_window(self, title: str, fps: int = _DEFAULT_FPS): with self._startup_profile_context(): def _close(sig, frame): self.close() @@ -274,8 +263,6 @@ class GuiApplication: signal.signal(signal.SIGINT, _close) atexit.register(self.close) - self._new_modal = new_modal - flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT if ENABLE_VSYNC: flags |= rl.ConfigFlags.FLAG_VSYNC_HINT @@ -380,44 +367,48 @@ class GuiApplication: break def push_widget(self, widget: object): - assert self._new_modal + if widget in self._nav_stack: + cloudlog.warning("Widget already in stack, cannot push again!") + return # disable previous widget to prevent input processing if len(self._nav_stack) > 0: prev_widget = self._nav_stack[-1] + # TODO: change these to touch_valid prev_widget.set_enabled(False) self._nav_stack.append(widget) widget.show_event() def pop_widget(self): - assert self._new_modal - if len(self._nav_stack) < 2: - cloudlog.warning("At least one widget should remain on the stack, ignoring pop") + cloudlog.warning("At least one widget should remain on the stack, ignoring pop!") return # re-enable previous widget and pop current + # TODO: switch to touch_valid prev_widget = self._nav_stack[-2] prev_widget.set_enabled(True) widget = self._nav_stack.pop() widget.hide_event() - def set_modal_overlay(self, overlay, callback: Callable | None = None): - assert not self._new_modal, "set_modal_overlay is deprecated, use push_widget instead" + def pop_widgets_to(self, widget): + if widget not in self._nav_stack: + cloudlog.warning("Widget not in stack, cannot pop to it!") + return - if self._modal_overlay.overlay is not None: - if hasattr(self._modal_overlay.overlay, 'hide_event'): - self._modal_overlay.overlay.hide_event() + # pops all widgets after specified widget + while len(self._nav_stack) > 0 and self._nav_stack[-1] != widget: + self.pop_widget() - if self._modal_overlay.callback is not None: - self._modal_overlay.callback(-1) + def get_active_widget(self): + if len(self._nav_stack) > 0: + return self._nav_stack[-1] + return None - self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback) - - def set_modal_overlay_tick(self, tick_function: Callable | None): - self._modal_overlay_tick = tick_function + def set_nav_stack_tick(self, tick_function: Callable | None): + self._nav_stack_tick = tick_function def set_should_render(self, should_render: bool): self._should_render = should_render @@ -561,23 +552,15 @@ class GuiApplication: rl.begin_drawing() rl.clear_background(rl.BLACK) - if self._new_modal: - # Only render last widget - for widget in self._nav_stack[-1:]: - widget.render(rl.Rectangle(0, 0, self.width, self.height)) + # Allow a Widget to still run a function regardless of the stack depth + if self._nav_stack_tick is not None: + self._nav_stack_tick() - # Yield to allow caller to run non-rendering related code - yield True + # Only render top widgets + for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]: + widget.render(rl.Rectangle(0, 0, self.width, self.height)) - else: - # Handle modal overlay rendering and input processing - if self._handle_modal_overlay(): - # Allow a Widget to still run a function while overlay is shown - if self._modal_overlay_tick is not None: - self._modal_overlay_tick() - yield False - else: - yield True + yield True if self._render_texture: rl.end_texture_mode() @@ -631,33 +614,6 @@ class GuiApplication: def height(self): return self._height - def _handle_modal_overlay(self) -> bool: - if self._modal_overlay.overlay: - if hasattr(self._modal_overlay.overlay, 'render'): - result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height)) - elif callable(self._modal_overlay.overlay): - result = self._modal_overlay.overlay() - else: - raise Exception - - # Send show event to Widget - if not self._modal_overlay_shown and hasattr(self._modal_overlay.overlay, 'show_event'): - self._modal_overlay.overlay.show_event() - self._modal_overlay_shown = True - - if result >= 0: - # Clear the overlay and execute the callback - original_modal = self._modal_overlay - self._modal_overlay = ModalOverlay() - if hasattr(original_modal.overlay, 'hide_event'): - original_modal.overlay.hide_event() - if original_modal.callback is not None: - original_modal.callback(result) - return True - else: - self._modal_overlay_shown = False - return False - def _load_fonts(self): for font_weight_file in FontWeight: with as_file(FONT_DIR) as fspath: diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py index 925afd7d1..357e67293 100755 --- a/system/ui/mici_reset.py +++ b/system/ui/mici_reset.py @@ -150,10 +150,10 @@ def main(): if mode == ResetMode.FORMAT: reset.start_reset() - for should_render in gui_app.render(): - if should_render: - if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)): - break + gui_app.push_widget(reset) + + for _ in gui_app.render(): + pass if __name__ == "__main__": diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py index b5c0d0528..aa6f54c50 100755 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -19,7 +19,7 @@ from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.wifi_manager import WifiManager from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 -from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton, SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton, FullRoundedButton) @@ -96,10 +96,9 @@ class SetupState(IntEnum): NETWORK_SETUP = 1 NETWORK_SETUP_CUSTOM_SOFTWARE = 2 SOFTWARE_SELECTION = 3 - CUSTOM_SOFTWARE = 4 - DOWNLOADING = 5 - DOWNLOAD_FAILED = 6 - CUSTOM_SOFTWARE_WARNING = 7 + DOWNLOADING = 4 + DOWNLOAD_FAILED = 5 + CUSTOM_SOFTWARE_WARNING = 6 class StartPage(Widget): @@ -128,7 +127,9 @@ class SoftwareSelectionPage(Widget): super().__init__() self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback) + self._openpilot_slider.set_enabled(lambda: self.enabled) self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False) + self._custom_software_slider.set_enabled(lambda: self.enabled) def reset(self): self._openpilot_slider.reset() @@ -390,9 +391,11 @@ class FailedPage(Widget): self._reboot_button = SmallRedPillButton("reboot") self._reboot_button.set_click_callback(reboot_callback) + self._reboot_button.set_enabled(lambda: self.enabled) # for nav stack self._retry_button = WideRoundedButton("retry") self._retry_button.set_click_callback(retry_callback) + self._retry_button.set_enabled(lambda: self.enabled) # for nav stack def set_reason(self, reason: str): self._reason_label.set_text(reason) @@ -427,15 +430,10 @@ class FailedPage(Widget): )) -class NetworkSetupState(IntEnum): - MAIN = 0 - WIFI_PANEL = 1 - - class NetworkSetupPage(Widget): def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable): super().__init__() - self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN)) + self._wifi_ui = WifiUIMici(wifi_manager) self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50) @@ -445,78 +443,54 @@ class NetworkSetupPage(Widget): back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32) self._back_button = SmallCircleIconButton(back_txt) self._back_button.set_click_callback(back_callback) + self._back_button.set_enabled(lambda: self.enabled) # for nav stack self._wifi_button = SmallerRoundedButton("wifi") - self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL)) + self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + self._wifi_button.set_enabled(lambda: self.enabled) self._continue_button = WidishRoundedButton("continue") self._continue_button.set_enabled(False) self._continue_button.set_click_callback(continue_callback) - self._state = NetworkSetupState.MAIN - self._prev_has_internet = False - - def set_state(self, state: NetworkSetupState): - if self._state == NetworkSetupState.WIFI_PANEL and state != NetworkSetupState.WIFI_PANEL: - self._wifi_ui.hide_event() - self._state = state - if state == NetworkSetupState.WIFI_PANEL: - self._wifi_ui.show_event() - def set_has_internet(self, has_internet: bool): if has_internet: self._network_header.set_title("connected to internet") self._network_header.set_icon(self._wifi_full_txt) - self._continue_button.set_enabled(True) + self._continue_button.set_enabled(self.enabled) else: self._network_header.set_title(self._waiting_text) self._network_header.set_icon(self._no_wifi_txt) self._continue_button.set_enabled(False) - if has_internet and not self._prev_has_internet: - self.set_state(NetworkSetupState.MAIN) - self._prev_has_internet = has_internet - - def show_event(self): - super().show_event() - self._state = NetworkSetupState.MAIN - - def hide_event(self): - super().hide_event() - if self._state == NetworkSetupState.WIFI_PANEL: - self._wifi_ui.hide_event() - def _render(self, _): - if self._state == NetworkSetupState.MAIN: - self._network_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16, - self._rect.width - 32, - self._network_header.rect.height, - )) + self._network_header.render(rl.Rectangle( + self._rect.x + 16, + self._rect.y + 16, + self._rect.width - 32, + self._network_header.rect.height, + )) - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) + self._back_button.render(rl.Rectangle( + self._rect.x + 8, + self._rect.y + self._rect.height - self._back_button.rect.height, + self._back_button.rect.width, + self._back_button.rect.height, + )) - self._wifi_button.render(rl.Rectangle( - self._rect.x + 8 + self._back_button.rect.width + 10, - self._rect.y + self._rect.height - self._wifi_button.rect.height, - self._wifi_button.rect.width, - self._wifi_button.rect.height, - )) + self._wifi_button.render(rl.Rectangle( + self._rect.x + 8 + self._back_button.rect.width + 10, + self._rect.y + self._rect.height - self._wifi_button.rect.height, + self._wifi_button.rect.width, + self._wifi_button.rect.height, + )) - self._continue_button.render(rl.Rectangle( - self._rect.x + self._rect.width - self._continue_button.rect.width - 8, - self._rect.y + self._rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) - else: - self._wifi_ui.render(self._rect) + self._continue_button.render(rl.Rectangle( + self._rect.x + self._rect.width - self._continue_button.rect.width - 8, + self._rect.y + self._rect.height - self._continue_button.rect.height, + self._continue_button.rect.width, + self._continue_button.rect.height, + )) class Setup(Widget): @@ -533,28 +507,33 @@ class Setup(Widget): self._network_monitor = NetworkConnectivityMonitor() self._network_monitor.start() self._prev_has_internet = False - gui_app.set_modal_overlay_tick(self._modal_overlay_tick) + gui_app.set_nav_stack_tick(self._nav_stack_tick) self._start_page = StartPage() self._start_page.set_click_callback(self._getting_started_button_callback) self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback, self._network_setup_back_button_callback) + # TODO: change these to touch_valid + self._network_setup_page.set_enabled(lambda: self.enabled) # for nav stack self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback, self._software_selection_custom_software_button_callback) + self._software_selection_page.set_enabled(lambda: self.enabled) # for nav stack self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback) + self._download_failed_page.set_enabled(lambda: self.enabled) # for nav stack self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue, self._custom_software_warning_back_button_callback) + self._custom_software_warning_page.set_enabled(lambda: self.enabled) # for nav stack self._downloading_page = DownloadingPage() - def _modal_overlay_tick(self): + def _nav_stack_tick(self): has_internet = self._network_monitor.network_connected.is_set() if has_internet and not self._prev_has_internet: - gui_app.set_modal_overlay(None) + gui_app.pop_widgets_to(self) self._prev_has_internet = has_internet def _update_state(self): @@ -582,8 +561,6 @@ class Setup(Widget): self._software_selection_page.render(rect) elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: self._custom_software_warning_page.render(rect) - elif self.state == SetupState.CUSTOM_SOFTWARE: - self.render_custom_software() elif self.state == SetupState.DOWNLOADING: self.render_downloading(rect) elif self.state == SetupState.DOWNLOAD_FAILED: @@ -614,14 +591,19 @@ class Setup(Widget): if self.state == SetupState.NETWORK_SETUP: self.download(OPENPILOT_URL) elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE: - self._set_state(SetupState.CUSTOM_SOFTWARE) + def handle_keyboard_result(text): + url = text.strip() + if url: + self.download(url) + + keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result) + gui_app.push_widget(keyboard) def close(self): self._network_monitor.stop() def render_network_setup(self, rect: rl.Rectangle): has_internet = self._network_monitor.network_connected.is_set() - self._prev_has_internet = has_internet self._network_setup_page.set_has_internet(has_internet) self._network_setup_page.render(rect) @@ -629,19 +611,6 @@ class Setup(Widget): self._downloading_page.set_progress(self.download_progress) self._downloading_page.render(rect) - def render_custom_software(self): - def handle_keyboard_result(text): - url = text.strip() - if url: - self.download(url) - - def handle_keyboard_exit(result): - if result == DialogResult.CANCEL: - self._set_state(SetupState.SOFTWARE_SELECTION) - - keyboard = BigInputDialog("custom software URL...", confirm_callback=handle_keyboard_result) - gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit) - def use_openpilot(self): if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): os.remove(VALID_CACHE_PATH) @@ -738,9 +707,9 @@ def main(): try: gui_app.init_window("Setup") setup = Setup() - for should_render in gui_app.render(): - if should_render: - setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + gui_app.push_widget(setup) + for _ in gui_app.render(): + pass setup.close() except Exception as e: print(f"Setup error: {e}") diff --git a/system/ui/mici_updater.py b/system/ui/mici_updater.py index 5c8748783..5de72ac8c 100755 --- a/system/ui/mici_updater.py +++ b/system/ui/mici_updater.py @@ -38,6 +38,7 @@ class Updater(Widget): self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback, self._network_setup_back_callback) + self._network_setup_page.set_enabled(lambda: self.enabled) # for nav stack self._network_monitor = NetworkConnectivityMonitor() self._network_monitor.start() @@ -182,9 +183,9 @@ def main(): try: gui_app.init_window("System Update") updater = Updater(updater_path, manifest_path) - for should_render in gui_app.render(): - if should_render: - updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + gui_app.push_widget(updater) + for _ in gui_app.render(): + pass updater.close() except Exception as e: print(f"Updater error: {e}") diff --git a/system/ui/tici_reset.py b/system/ui/tici_reset.py index b22b24085..23f6b344e 100755 --- a/system/ui/tici_reset.py +++ b/system/ui/tici_reset.py @@ -116,7 +116,7 @@ def main(): elif sys.argv[1] == "--format": mode = ResetMode.FORMAT - gui_app.init_window("System Reset", 20, new_modal=True) + gui_app.init_window("System Reset", 20) reset = Reset(mode) if mode == ResetMode.FORMAT: diff --git a/system/ui/tici_setup.py b/system/ui/tici_setup.py index bb70b75b2..39f95cc8a 100755 --- a/system/ui/tici_setup.py +++ b/system/ui/tici_setup.py @@ -436,7 +436,7 @@ class Setup(Widget): def main(): try: - gui_app.init_window("Setup", 20, new_modal=True) + gui_app.init_window("Setup", 20) setup = Setup() gui_app.push_widget(setup) for _ in gui_app.render(): diff --git a/system/ui/tici_updater.py b/system/ui/tici_updater.py index c040e1a40..9824638cd 100755 --- a/system/ui/tici_updater.py +++ b/system/ui/tici_updater.py @@ -160,7 +160,7 @@ def main(): manifest_path = sys.argv[2] try: - gui_app.init_window("System Update", new_modal=True) + gui_app.init_window("System Update") gui_app.push_widget(Updater(updater_path, manifest_path)) for _ in gui_app.render(): pass diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index b3b1276a5..25c908c72 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -108,6 +108,10 @@ class Widget(abc.ABC): # Keep track of whether mouse down started within the widget's rectangle if self.enabled and self.__was_awake: self._process_mouse_events() + else: + # TODO: ideally we emit release events when going disabled + self.__is_pressed = [False] * MAX_TOUCH_SLOTS + self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS self.__was_awake = device.awake @@ -181,6 +185,7 @@ class Widget(abc.ABC): def show_event(self): """Optionally handle show event. Parent must manually call this""" + # TODO: iterate through all child objects, check for subclassing from Widget/Layout (Scroller) def hide_event(self): """Optionally handle hide event. Parent must manually call this""" @@ -261,6 +266,7 @@ class NavWidget(Widget, abc.ABC): self._back_callback = callback def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: + # FIXME: disabling this widget on new push_widget still causes this widget to track mouse events without mouse down super()._handle_mouse_event(mouse_event) if not self.back_enabled: @@ -320,13 +326,14 @@ class NavWidget(Widget, abc.ABC): 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: not self._swiping_away and (original_enabled() if callable(original_enabled) else - original_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: not self._swiping_away and (original_enabled() if callable(original_enabled) else - original_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 @@ -336,6 +343,10 @@ class NavWidget(Widget, abc.ABC): new_y = 0.0 + if not self.enabled: + self._back_button_start_pos = None + + # TODO: why is this not in handle_mouse_event? have to hack above if self._back_button_start_pos is not None: last_mouse_event = gui_app.last_mouse_event # push entire widget as user drags it away @@ -363,6 +374,14 @@ class NavWidget(Widget, abc.ABC): self.set_position(self._rect.x, new_y) + def _layout(self): + # Dim whatever is behind this widget, fading with position (runs after _update_state so position is correct) + overlay_alpha = int(200 * max(0.0, min(1.0, 1.0 - self._rect.y / self._rect.height))) if self._rect.height > 0 else 0 + rl.draw_rectangle(0, 0, int(self._rect.width), int(self._rect.height), rl.Color(0, 0, 0, overlay_alpha)) + + bounce_height = 20 + rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height + bounce_height), rl.BLACK) + def render(self, rect: rl.Rectangle | None = None) -> bool | int | None: ret = super().render(rect) @@ -379,10 +398,6 @@ class NavWidget(Widget, abc.ABC): else: self._nav_bar_y_filter.update(NAV_BAR_MARGIN) - # draw black above widget when dismissing - if self._rect.y > 0: - rl.draw_rectangle(int(self._rect.x), 0, int(self._rect.width), int(self._rect.y), rl.BLACK) - self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x)) self._nav_bar.render() diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index 531725688..49c59a431 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -272,7 +272,7 @@ if __name__ == "__main__": print("Canceled") gui_app.request_close() - gui_app.init_window("Keyboard", new_modal=True) + gui_app.init_window("Keyboard") keyboard = Keyboard(min_text_size=8, show_password_toggle=True, callback=callback) keyboard.set_title("Keyboard Input", "Type your text below") diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py index 6d2e08e05..59a245138 100644 --- a/system/ui/widgets/mici_keyboard.py +++ b/system/ui/widgets/mici_keyboard.py @@ -61,7 +61,7 @@ class Key(Widget): self._x_filter.x = x self._y_filter.x = local_y # keep track of original position so dragging around feels consistent. also move touch area down a bit - self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET) + self.original_position = rl.Vector2(x, local_y + KEY_TOUCH_AREA_OFFSET) self._position_initialized = True if not smooth: @@ -227,6 +227,8 @@ class MiciKeyboard(Widget): for current_row, row in zip(self._current_keys, keys, strict=False): # not all layouts have the same number of keys for current_key, key in zip_repeat(current_row, row): + # reset parent rect for new keys + key.set_parent_rect(self._rect) current_pos = current_key.get_position() key.set_position(current_pos[0], current_pos[1], smooth=False) @@ -264,7 +266,8 @@ class MiciKeyboard(Widget): for key in row: mouse_pos = gui_app.last_mouse_event.pos # approximate distance for comparison is accurate enough - dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y) + # use local y coords so parent widget offset (e.g. during NavWidget animate-in) doesn't affect hit testing + dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - (mouse_pos.y - self._rect.y)) if dist < closest_key[1]: if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS: closest_key = (key, dist) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index fcd56607c..668565a03 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -481,7 +481,7 @@ class WifiManagerUI(Widget): def main(): - gui_app.init_window("Wi-Fi Manager", new_modal=True) + gui_app.init_window("Wi-Fi Manager") gui_app.push_widget(WifiManagerUI(WifiManager())) for _ in gui_app.render():