ui: add ModalOverlay system for unified modal dialog management (#35478)

add ModalOverlay
This commit is contained in:
Dean Lee
2025-06-07 04:18:07 +08:00
committed by GitHub
parent 75434b10b9
commit 6767bfce44
6 changed files with 70 additions and 47 deletions
-4
View File
@@ -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()
+27 -1
View File
@@ -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()
+12 -14
View File
@@ -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
-2
View File
@@ -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"):
+12 -5
View File
@@ -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()
+19 -21
View File
@@ -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)