From 6767bfce445fe3ed2bd6617a05c2851257b3c5e1 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Sat, 7 Jun 2025 04:18:07 +0800 Subject: [PATCH] ui: add ModalOverlay system for unified modal dialog management (#35478) add ModalOverlay --- selfdrive/ui/layouts/network.py | 4 ---- system/ui/lib/application.py | 28 ++++++++++++++++++++++- system/ui/setup.py | 26 ++++++++++----------- system/ui/updater.py | 2 -- system/ui/widgets/keyboard.py | 17 +++++++++----- system/ui/widgets/network.py | 40 ++++++++++++++++----------------- 6 files changed, 70 insertions(+), 47 deletions(-) diff --git a/selfdrive/ui/layouts/network.py b/selfdrive/ui/layouts/network.py index bf52f1887..834234c47 100644 --- a/selfdrive/ui/layouts/network.py +++ b/selfdrive/ui/layouts/network.py @@ -11,9 +11,5 @@ class NetworkLayout: def render(self, rect: rl.Rectangle): self.wifi_ui.render(rect) - @property - def require_full_screen(self): - return self.wifi_ui.require_full_screen - def shutdown(self): self.wifi_manager.shutdown() diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index c2d6ac8e2..0dd6a6157 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -2,6 +2,8 @@ import atexit import os import time import pyray as rl +from collections.abc import Callable +from dataclasses import dataclass from enum import IntEnum from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog @@ -36,6 +38,11 @@ class FontWeight(IntEnum): BLACK = 8 +@dataclass +class ModalOverlay: + overlay: object = None + callback: Callable | None = None + class GuiApplication: def __init__(self, width: int, height: int): self._fonts: dict[FontWeight, rl.Font] = {} @@ -50,6 +57,8 @@ class GuiApplication: self._last_fps_log_time: float = time.monotonic() self._window_close_requested = False self._trace_log_callback = None + self._modal_overlay = ModalOverlay() + def request_close(self): self._window_close_requested = True @@ -79,6 +88,9 @@ class GuiApplication: self._set_styles() self._load_fonts() + def set_modal_overlay(self, overlay, callback: Callable | None = None): + self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback) + def texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True): cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" if cache_key in self._textures: @@ -148,7 +160,21 @@ class GuiApplication: rl.begin_drawing() rl.clear_background(rl.BLACK) - yield + # Handle modal overlay rendering and input processing + 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: + assert(0) + + if result >= 0 and self._modal_overlay.callback is not None: + # Execute callback with the result and clear the overlay + self._modal_overlay.callback(result) + self._modal_overlay = ModalOverlay() + else: + yield if self._render_texture: rl.end_texture_mode() diff --git a/system/ui/setup.py b/system/ui/setup.py index d06384596..73573c950 100755 --- a/system/ui/setup.py +++ b/system/ui/setup.py @@ -143,10 +143,6 @@ class Setup: self.network_check_thread.join() def render_network_setup(self, rect: rl.Rectangle): - if self.wifi_ui.require_full_screen: - self.wifi_ui.render(rect) - return - title_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE) gui_label(title_rect, "Connect to Wi-Fi", TITLE_FONT_SIZE, font_weight=FontWeight.MEDIUM) @@ -255,18 +251,20 @@ class Setup: self.state = SetupState.GETTING_STARTED def render_custom_url(self): - result = self.keyboard.render("Enter URL", "for Custom Software") + def handle_keyboard_result(result): + # Enter pressed + if result == 1: + url = self.keyboard.text + self.keyboard.clear() + if url: + self.download(url) - # Enter pressed - if result == 1: - url = self.keyboard.text - self.keyboard.clear() - if url: - self.download(url) + # Cancel pressed + elif result == 0: + self.state = SetupState.SOFTWARE_SELECTION - # Cancel pressed - elif result == 0: - self.state = SetupState.SOFTWARE_SELECTION + self.keyboard.set_title("Enter URL", "for Custom Software") + gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result) def download(self, url: str): # autocomplete incomplete URLs diff --git a/system/ui/updater.py b/system/ui/updater.py index 3ee02ce97..b49d9a4d3 100755 --- a/system/ui/updater.py +++ b/system/ui/updater.py @@ -110,8 +110,6 @@ class Updater: # Draw the Wi-Fi manager UI wifi_rect = rl.Rectangle(MARGIN + 50, MARGIN, gui_app.width - MARGIN * 2 - 100, gui_app.height - MARGIN * 2 - BUTTON_HEIGHT - 20) self.wifi_manager_ui.render(wifi_rect) - if self.wifi_manager_ui.require_full_screen: - return back_button_rect = rl.Rectangle(MARGIN, gui_app.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT) if gui_button(back_button_rect, "Back"): diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index 0e22f1b52..03ed81da2 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -57,6 +57,8 @@ class Keyboard: self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._caps_lock = False self._last_shift_press_time = 0 + self._title = "" + self._sub_title = "" self._max_text_size = max_text_size self._min_text_size = min_text_size @@ -89,10 +91,14 @@ class Keyboard: self._input_box.clear() self._backspace_pressed = False - def render(self, title: str, sub_title: str): - rect = rl.Rectangle(CONTENT_MARGIN, CONTENT_MARGIN, gui_app.width - 2 * CONTENT_MARGIN, gui_app.height - 2 * CONTENT_MARGIN) - gui_label(rl.Rectangle(rect.x, rect.y, rect.width, 95), title, 90, font_weight=FontWeight.BOLD) - gui_label(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60), sub_title, 55, font_weight=FontWeight.NORMAL) + def set_title(self, title: str, sub_title: str=""): + self._title = title + self._sub_title = sub_title + + def render(self, rect: rl.Rectangle): + rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN) + gui_label(rl.Rectangle(rect.x, rect.y, rect.width, 95), self._title, 90, font_weight=FontWeight.BOLD) + gui_label(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60), self._sub_title, 55, font_weight=FontWeight.NORMAL) if gui_button(rl.Rectangle(rect.x + rect.width - 386, rect.y, 386, 125), "Cancel"): self.clear() return 0 @@ -223,7 +229,8 @@ if __name__ == "__main__": gui_app.init_window("Keyboard") keyboard = Keyboard(min_text_size=8, show_password_toggle=True) for _ in gui_app.render(): - result = keyboard.render("Keyboard", "Type here") + keyboard.set_title("Keyboard Input", "Type your text below") + result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) if result == 1: print(f"You typed: {keyboard.text}") gui_app.request_close() diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 80f55ebf5..75794eddd 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -82,31 +82,29 @@ class WifiManagerUI: match self.state: case StateNeedsAuth(network): - result = self.keyboard.render("Enter password", f"for {network.ssid}") - if result == 1: - password = self.keyboard.text - self.keyboard.clear() - - if len(password) >= MIN_PASSWORD_LENGTH: - self.connect_to_network(network, password) - elif result == 0: - self.state = StateIdle() - + self.keyboard.set_title("Enter password", f"for {network.ssid}") + gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(network, result)) case StateShowForgetConfirm(network): - result = confirm_dialog(f'Forget Wi-Fi Network "{network.ssid}"?', "Forget") - if result == 1: - self.forget_network(network) - elif result == 0: - self.state = StateIdle() - + gui_app.set_modal_overlay(lambda: confirm_dialog(f'Forget Wi-Fi Network "{network.ssid}"?', "Forget"), + callback=lambda result: self.on_forgot_confirm_finished(network, result)) case _: self._draw_network_list(rect) - @property - def require_full_screen(self) -> bool: - """Check if the WiFi UI requires exclusive full-screen rendering.""" - with self._lock: - return isinstance(self.state, (StateNeedsAuth, StateShowForgetConfirm)) + def _on_password_entered(self, network: NetworkInfo, result: int): + if result == 1: + password = self.keyboard.text + self.keyboard.clear() + + if len(password) >= MIN_PASSWORD_LENGTH: + self.connect_to_network(network, password) + elif result == 0: + self.state = StateIdle() + + def on_forgot_confirm_finished(self, network, result: int): + if result == 1: + self.forget_network(network) + elif result == 0: + self.state = StateIdle() def _draw_network_list(self, rect: rl.Rectangle): content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT)