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
This commit is contained in:
Shane Smiskol
2026-02-20 19:00:27 -08:00
committed by GitHub
parent c98ba4ff4c
commit 30350f4207
31 changed files with 260 additions and 472 deletions
+29 -52
View File
@@ -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')
+7 -7
View File
@@ -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
@@ -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 ""
+11 -27
View File
@@ -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()
@@ -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)
@@ -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)
@@ -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(_)
+20 -69
View File
@@ -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)
@@ -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")
@@ -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():
@@ -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()
@@ -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",
+12 -26
View File
@@ -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)
+4 -7
View File
@@ -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
+1 -1
View File
@@ -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***")
+1 -1
View File
@@ -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)
+2 -6
View File
@@ -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):
+1 -4
View File
@@ -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()
+3 -9
View File
@@ -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:
+1 -1
View File
@@ -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:
+29 -73
View File
@@ -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:
+4 -4
View File
@@ -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__":
+55 -86
View File
@@ -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}")
+4 -3
View File
@@ -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}")
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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():
+1 -1
View File
@@ -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
+23 -8
View File
@@ -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()
+1 -1
View File
@@ -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")
+5 -2
View File
@@ -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)
+1 -1
View File
@@ -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():