Merge remote-tracking branch 'comma/master' into sync-20250627

# Conflicts:
#	README.md
#	opendbc_repo
#	panda
#	selfdrive/ui/qt/offroad/settings.cc
#	selfdrive/ui/translations/main_ar.ts
#	selfdrive/ui/translations/main_de.ts
#	selfdrive/ui/translations/main_es.ts
#	selfdrive/ui/translations/main_fr.ts
#	selfdrive/ui/translations/main_ja.ts
#	selfdrive/ui/translations/main_ko.ts
#	selfdrive/ui/translations/main_pt-BR.ts
#	selfdrive/ui/translations/main_th.ts
#	selfdrive/ui/translations/main_tr.ts
#	selfdrive/ui/translations/main_zh-CHS.ts
#	selfdrive/ui/translations/main_zh-CHT.ts
#	system/manager/build.py
#	system/ui/spinner.py
#	tinygrad_repo
#	tools/lib/framereader.py
Merge branch 'comma-202506127-bedcb896644528aed6af448e63eeadb3dd8b2c77' into sync-20250627

# Conflicts:
#	opendbc/safety/tests/libsafety/SConscript
Merge branch 'comma-202506127-bedcb896644528aed6af448e63eeadb3dd8b2c77' into sync-20250627

# Conflicts:
#	opendbc/safety/tests/libsafety/SConscript
Merge remote-tracking branch 'comma/master' into sync-20250627

# Conflicts:
#	opendbc/safety/tests/libsafety/SConscript
Merge branch 'comma-20250627-1020d355584265391eb3acb556e4353b581fa9c0' into sync-20250627
Merge branch 'comma-20250627-1020d355584265391eb3acb556e4353b581fa9c0' into sync-20250627
Sync: `commaai/opendbc:master` into `sunnypilot/opendbc:master-new`

Sync: `commaai/opendbc:master` into `sunnypilot/opendbc:master-new`
Sync: `commaai/panda:master` into `sunnypilot/panda:master-new`

Sync: `commaai/panda:master` into `sunnypilot/panda:master-new`
This commit is contained in:
DevTekVE
2025-06-27 16:44:01 +02:00
115 changed files with 4113 additions and 2989 deletions
+1 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from datetime import datetime, timedelta, UTC
from openpilot.common.api import api_get
from openpilot.common.params import Params
from openpilot.common.spinner import Spinner
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.hardware.hw import Paths
@@ -44,7 +45,6 @@ def register(show_spinner=False) -> str | None:
cloudlog.warning(f"missing public key: {pubkey}")
elif dongle_id is None:
if show_spinner:
from openpilot.system.ui.spinner import Spinner
spinner = Spinner()
spinner.update("registering device")
+10 -10
View File
@@ -56,28 +56,28 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42.img.xz",
"hash": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"hash_raw": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
"hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"size": 18479104,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "6b7b3371100ad36d8a5a9ff19a1663b9b9e2d5e99cbe3cf9255e9c3017291ce3"
"ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img.xz",
"hash": "993d6a1cd2b684e2b1cf6ff840f8996f02a529011372d9c1471e4c80719e7da9",
"hash_raw": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
"hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
"hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"size": 5368709120,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "59db25651da977eeb16a1af741fd01fc3d6b50d21544b1a7428b7c86b2cdef2d",
"ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
"alt": {
"hash": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img",
"hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
"size": 5368709120
}
}
+22 -22
View File
@@ -339,62 +339,62 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42.img.xz",
"hash": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"hash_raw": "4143170bad94968fd9be870b1498b4100bf273ed0aec2a2601c9017991d4bd42",
"url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
"hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
"size": 18479104,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "6b7b3371100ad36d8a5a9ff19a1663b9b9e2d5e99cbe3cf9255e9c3017291ce3"
"ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img.xz",
"hash": "993d6a1cd2b684e2b1cf6ff840f8996f02a529011372d9c1471e4c80719e7da9",
"hash_raw": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
"hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
"hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"size": 5368709120,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "59db25651da977eeb16a1af741fd01fc3d6b50d21544b1a7428b7c86b2cdef2d",
"ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
"alt": {
"hash": "c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa",
"url": "https://commadist.azureedge.net/agnosupdate/system-c51bb5841011728f7cf108a9138ba68228ffb4232dfd91d6e082a6d8a6a8deaa.img",
"hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
"size": 5368709120
}
},
{
"name": "userdata_90",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-89a161f17b86637413fe10a641550110b626b699382f5138c02267b7866a8494.img.xz",
"hash": "99d9e6cf6755581c6879bbf442bd62212beb8a04116e965ab987135b8842188b",
"hash_raw": "89a161f17b86637413fe10a641550110b626b699382f5138c02267b7866a8494",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-f0c675e0fae420870c9ba8979fa246b170f4f1a7a04b49609b55b6bdfa8c1b21.img.xz",
"hash": "3d8a007bae088c5959eb9b82454013f91868946d78380fecea2b1afdfb575c02",
"hash_raw": "f0c675e0fae420870c9ba8979fa246b170f4f1a7a04b49609b55b6bdfa8c1b21",
"size": 96636764160,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "24ea29ab9c4ecec0568a4aa83e38790fedfce694060e90f4bde725931386ff41"
"ondevice_hash": "5bfbabb8ff96b149056aa75d5b7e66a7cdd9cb4bcefe23b922c292f7f3a43462"
},
{
"name": "userdata_89",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-cdd3401168819987c4840765bba1aa2217641b1a6a4165c412f44cac14ccfcbf.img.xz",
"hash": "5fbfa008a7f6b58ab01d4d171f3185924d4c9db69b54f4bfc0f214c6f17c2435",
"hash_raw": "cdd3401168819987c4840765bba1aa2217641b1a6a4165c412f44cac14ccfcbf",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-06fc52be37b42690ed7b4f8c66c4611309a2dea9fca37dd9d27d1eff302eb1bf.img.xz",
"hash": "443f136484294b210318842d09fb618d5411c8bdbab9f7421d8c89eb291a8d3f",
"hash_raw": "06fc52be37b42690ed7b4f8c66c4611309a2dea9fca37dd9d27d1eff302eb1bf",
"size": 95563022336,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "c07dc2e883a23d4a24d976cdf53a767a2fd699c8eeb476d60cdf18e84b417a52"
"ondevice_hash": "67db02b29a7e4435951c64cc962a474d048ed444aa912f3494391417cd51a074"
},
{
"name": "userdata_30",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-2a8e8278b3bb545e6d7292c2417ccebdca9b47507eb5924f7c1e068737a7edfd.img.xz",
"hash": "b3bc293c9c5e0480ef663e980c8ccb2fb83ffd230c85f8797830fb61b8f59360",
"hash_raw": "2a8e8278b3bb545e6d7292c2417ccebdca9b47507eb5924f7c1e068737a7edfd",
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-06679488f0c5c3fcfd5f351133050751cd189f705e478a979c45fc4a166d18a6.img.xz",
"hash": "875b580cb786f290a842e9187fd945657561886123eb3075a26f7995a18068f6",
"hash_raw": "06679488f0c5c3fcfd5f351133050751cd189f705e478a979c45fc4a166d18a6",
"size": 32212254720,
"sparse": true,
"full_check": true,
"has_ab": false,
"ondevice_hash": "8dae1cda089828c750d1d646337774ccd9432f567ecefde19a06dc7feeda9cd3"
"ondevice_hash": "16e27ba3c5cf9f0394ce6235ba6021b8a2de293fdb08399f8ca832fa5e4d0b9d"
}
]
+30
View File
@@ -0,0 +1,30 @@
[connection]
id=esim
uuid=fff6553c-3284-4707-a6b1-acc021caaafb
type=gsm
permissions=
autoconnect=true
autoconnect-retries=100
autoconnect-priority=2
metered=1
[gsm]
apn=
home-only=false
auto-config=true
sim-id=
[ipv4]
route-metric=1000
dns-priority=1000
dns-search=
method=auto
[ipv6]
ddr-gen-mode=stable-privacy
dns-search=
route-metric=1000
dns-priority=1000
method=auto
[proxy]
+11 -11
View File
@@ -3,6 +3,7 @@ import math
import os
import subprocess
import time
import tempfile
from enum import IntEnum
from functools import cached_property, lru_cache
from pathlib import Path
@@ -499,19 +500,18 @@ class Tici(HardwareBase):
except Exception:
pass
# we use the lte connection built into AGNOS. cleanup esim connection if it exists
# eSIM prime
dest = "/etc/NetworkManager/system-connections/esim.nmconnection"
if os.path.exists(dest):
os.system(f"sudo nmcli con delete {dest}")
self.reboot_modem()
if sim_id.startswith('8985235') and not os.path.exists(dest):
with open(Path(__file__).parent/'esim.nmconnection') as f, tempfile.NamedTemporaryFile(mode='w') as tf:
dat = f.read()
dat = dat.replace("sim-id=", f"sim-id={sim_id}")
tf.write(dat)
tf.flush()
def reboot_modem(self):
modem = self.get_modem()
for state in (0, 1):
try:
modem.Command(f'AT+CFUN={state}', math.ceil(TIMEOUT), dbus_interface=MM_MODEM, timeout=TIMEOUT)
except Exception:
pass
# needs to be root
os.system(f"sudo cp {tf.name} {dest}")
os.system(f"sudo nmcli con load {dest}")
def get_networks(self):
r = {}
+7 -7
View File
@@ -5,16 +5,16 @@ from pathlib import Path
# NOTE: Do NOT import anything here that needs be built (e.g. params)
from openpilot.common.basedir import BASEDIR
from openpilot.common.spinner import Spinner
from openpilot.common.text_window import TextWindow
from openpilot.common.swaglog import cloudlog, add_file_handler
from openpilot.system.hardware import HARDWARE, AGNOS
from openpilot.system.ui.spinner import Spinner
from openpilot.system.ui.text import TextWindow
from openpilot.system.version import get_build_metadata
MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9
CACHE_DIR = Path("/data/scons_cache" if AGNOS else "/tmp/scons_cache")
TOTAL_SCONS_NODES = 3765
TOTAL_SCONS_NODES = 3800
MAX_BUILD_PROGRESS = 100
def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
@@ -88,7 +88,7 @@ def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
if __name__ == "__main__":
with Spinner() as spinner:
spinner.update_progress(0, 100)
build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)
spinner = Spinner()
spinner.update_progress(0, 100)
build_metadata = get_build_metadata()
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)
+1 -2
View File
@@ -9,6 +9,7 @@ from cereal import log
import cereal.messaging as messaging
import openpilot.system.sentry as sentry
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.text_window import TextWindow
from openpilot.system.hardware import HARDWARE
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog
from openpilot.system.manager.process import ensure_running
@@ -234,8 +235,6 @@ def main() -> None:
if __name__ == "__main__":
from openpilot.system.ui.text import TextWindow
unblock_stdout()
try:
+52 -7
View File
@@ -1,7 +1,10 @@
import atexit
import cffi
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
@@ -12,7 +15,7 @@ FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops
FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning
FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC") == "1"
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "1") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == '1'
STRICT_MODE = os.getenv("STRICT_MODE") == '1'
SCALE = float(os.getenv("SCALE", "1.0"))
@@ -36,6 +39,12 @@ 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 +59,7 @@ 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 +89,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 +161,23 @@ 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:
raise Exception
if result >= 0:
# Execute callback with the result and clear the overlay
if self._modal_overlay.callback is not None:
self._modal_overlay.callback(result)
self._modal_overlay = ModalOverlay()
else:
yield
if self._render_texture:
rl.end_texture_mode()
@@ -192,12 +221,11 @@ class GuiApplication:
# Create a character set from our keyboard layouts
from openpilot.system.ui.widgets.keyboard import KEYBOARD_LAYOUTS
from openpilot.selfdrive.ui.onroad.hud_renderer import CRUISE_DISABLED_CHAR
all_chars = set()
for layout in KEYBOARD_LAYOUTS.values():
all_chars.update(key for row in layout for key in row)
all_chars = "".join(all_chars)
all_chars += CRUISE_DISABLED_CHAR
all_chars += "–✓°"
codepoint_count = rl.ffi.new("int *", 1)
codepoints = rl.load_codepoints(all_chars, codepoint_count)
@@ -219,12 +247,29 @@ class GuiApplication:
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255)))
def _set_log_callback(self):
ffi_libc = cffi.FFI()
ffi_libc.cdef("""
int vasprintf(char **strp, const char *fmt, void *ap);
void free(void *ptr);
""")
libc = ffi_libc.dlopen(None)
@rl.ffi.callback("void(int, char *, void *)")
def trace_log_callback(log_level, text, args):
try:
text_str = rl.ffi.string(text).decode('utf-8')
except (TypeError, UnicodeDecodeError):
text_str = str(text)
text_addr = int(rl.ffi.cast("uintptr_t", text))
args_addr = int(rl.ffi.cast("uintptr_t", args))
text_libc = ffi_libc.cast("char *", text_addr)
args_libc = ffi_libc.cast("void *", args_addr)
out = ffi_libc.new("char **")
if libc.vasprintf(out, text_libc, args_libc) >= 0 and out[0] != ffi_libc.NULL:
text_str = ffi_libc.string(out[0]).decode("utf-8", "replace")
libc.free(out[0])
else:
text_str = rl.ffi.string(text).decode("utf-8", "replace")
except Exception as e:
text_str = f"[Log decode error: {e}]"
if log_level == rl.TraceLogLevel.LOG_ERROR:
cloudlog.error(f"raylib: {text_str}")
+13 -4
View File
@@ -10,6 +10,7 @@ class ButtonStyle(IntEnum):
DANGER = 2 # For critical actions, like reboot or delete
TRANSPARENT = 3 # For buttons with transparent background and border
ACTION = 4
LIST_ACTION = 5 # For list items with action buttons
class TextAlignment(IntEnum):
@@ -20,11 +21,17 @@ class TextAlignment(IntEnum):
ICON_PADDING = 15
DEFAULT_BUTTON_FONT_SIZE = 60
BUTTON_ENABLED_TEXT_COLOR = rl.Color(228, 228, 228, 255)
BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51)
ACTION_BUTTON_FONT_SIZE = 48
ACTION_BUTTON_TEXT_COLOR = rl.Color(0, 0, 0, 255)
BUTTON_TEXT_COLOR = {
ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255),
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(0, 0, 0, 255),
ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255),
}
BUTTON_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
@@ -32,6 +39,7 @@ BUTTON_BACKGROUND_COLORS = {
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
}
BUTTON_PRESSED_BACKGROUND_COLORS = {
@@ -40,6 +48,7 @@ BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74),
}
_pressed_buttons: set[str] = set() # Track mouse press state globally
@@ -133,7 +142,7 @@ def gui_button(
# Draw the button text if any
if text:
text_color = ACTION_BUTTON_TEXT_COLOR if button_style == ButtonStyle.ACTION else BUTTON_ENABLED_TEXT_COLOR if is_enabled else BUTTON_DISABLED_TEXT_COLOR
rl.draw_text_ex(font, text, text_pos, font_size, 0, text_color)
color = BUTTON_TEXT_COLOR[button_style] if is_enabled else BUTTON_DISABLED_TEXT_COLOR
rl.draw_text_ex(font, text, text_pos, font_size, 0, color)
return result
+1 -1
View File
@@ -4,7 +4,6 @@ from dataclasses import dataclass
from typing import Any
from openpilot.common.swaglog import cloudlog
# EGL constants
EGL_LINUX_DMA_BUF_EXT = 0x3270
EGL_WIDTH = 0x3057
@@ -23,6 +22,7 @@ GL_TEXTURE_EXTERNAL_OES = 0x8D65
# DRM Format for NV12
DRM_FORMAT_NV12 = 842094158
@dataclass
class EGLImage:
"""Container for EGL image and associated resources"""
+12 -11
View File
@@ -2,14 +2,15 @@ import pyray as rl
import time
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.widget import Widget
PASSWORD_MASK_CHAR = ""
PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking
class InputBox:
class InputBox(Widget):
def __init__(self, max_text_size=255, password_mode=False):
super().__init__()
self._max_text_size = max_text_size
self._input_text = ""
self._cursor_position = 0
@@ -23,7 +24,7 @@ class InputBox:
self._text_offset = 0
self._visible_width = 0
self._last_char_time = 0 # Track when last character was added
self._masked_length = 0 # How many characters are currently masked
self._masked_length = 0 # How many characters are currently masked
@property
def text(self):
@@ -76,11 +77,11 @@ class InputBox:
def add_char_at_cursor(self, char):
"""Add a character at the current cursor position."""
if len(self._input_text) < self._max_text_size:
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position :]
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position + 1)
if self._password_mode:
self._last_char_time = time.time()
self._last_char_time = time.monotonic()
return True
return False
@@ -88,7 +89,7 @@ class InputBox:
def delete_char_before_cursor(self):
"""Delete the character before the cursor position (backspace)."""
if self._cursor_position > 0:
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position :]
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position - 1)
return True
return False
@@ -96,12 +97,12 @@ class InputBox:
def delete_char_at_cursor(self):
"""Delete the character at the cursor position (delete)."""
if self._cursor_position < len(self._input_text):
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1 :]
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1:]
self.set_cursor_position(self._cursor_position)
return True
return False
def render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
def _render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
# Store dimensions for text offset calculations
self._visible_width = rect.width
self._font_size = font_size
@@ -160,18 +161,18 @@ class InputBox:
# Show character at last edited position if within delay window
masked_text = PASSWORD_MASK_CHAR * len(self._input_text)
recent_edit = time.time() - self._last_char_time < PASSWORD_MASK_DELAY
recent_edit = time.monotonic() - self._last_char_time < PASSWORD_MASK_DELAY
if recent_edit and self._input_text:
last_pos = max(0, self._cursor_position - 1)
if last_pos < len(self._input_text):
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1 :]
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1:]
return masked_text
def _handle_mouse_input(self, rect, font_size):
"""Handle mouse clicks to position cursor."""
mouse_pos = rl.get_mouse_position()
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON) and rl.check_collision_point_rec(mouse_pos, rect):
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(mouse_pos, rect):
# Calculate cursor position from click
if len(self._input_text) > 0:
font = gui_app.font()
-1
View File
@@ -76,4 +76,3 @@ def gui_text_box(
if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(FontWeight.NORMAL))
+269 -281
View File
@@ -1,27 +1,22 @@
import os
import pyray as rl
from dataclasses import dataclass
from collections.abc import Callable
from abc import ABC, abstractmethod
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from abc import ABC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.button import gui_button
from openpilot.system.ui.lib.toggle import Toggle
from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.widget import Widget
LINE_PADDING = 40
LINE_COLOR = rl.GRAY
ITEM_PADDING = 20
ITEM_SPACING = 80
ITEM_BASE_WIDTH = 600
ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20
ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 130
ITEM_DESC_V_OFFSET = 140
RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80
BUTTON_WIDTH = 250
@@ -30,38 +25,42 @@ BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
TEXT_PADDING = 20
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
# Abstract base class for right-side items
class RightItem(ABC):
def __init__(self, width: int = 100):
self.width = width
self.enabled = True
class ItemAction(Widget, ABC):
def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled
@abstractmethod
def draw(self, rect: rl.Rectangle) -> bool:
pass
@abstractmethod
def get_width(self) -> int:
pass
@property
def enabled(self):
return _resolve_value(self._enabled_source, False)
class ToggleRightItem(RightItem):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH):
super().__init__(width)
class ToggleAction(ItemAction):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(width, enabled)
self.toggle = Toggle(initial_state=initial_state)
self.state = initial_state
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool:
if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)):
self.state = not self.state
return True
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.toggle.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle) -> bool:
self.toggle.set_enabled(self.enabled)
self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT))
return False
def get_width(self) -> int:
return self.width
def set_state(self, state: bool):
self.state = state
self.toggle.set_state(state)
@@ -69,293 +68,269 @@ class ToggleRightItem(RightItem):
def get_state(self) -> bool:
return self.state
def set_enabled(self, enabled: bool):
self.enabled = enabled
class ButtonAction(ItemAction):
def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(width, enabled)
self._text_source = text
@property
def text(self):
return _resolve_value(self._text_source, "Error")
def _render(self, rect: rl.Rectangle) -> bool:
return gui_button(
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
self.text,
border_radius=BUTTON_BORDER_RADIUS,
font_weight=BUTTON_FONT_WEIGHT,
font_size=BUTTON_FONT_SIZE,
button_style=ButtonStyle.LIST_ACTION,
is_enabled=self.enabled,
) == 1
class ButtonRightItem(RightItem):
def __init__(self, text: str, width: int = BUTTON_WIDTH):
super().__init__(width)
self.text = text
self.enabled = True
def draw(self, rect: rl.Rectangle) -> bool:
return (
gui_button(
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
self.text,
border_radius=BUTTON_BORDER_RADIUS,
font_weight=BUTTON_FONT_WEIGHT,
font_size=BUTTON_FONT_SIZE,
is_enabled=self.enabled,
)
== 1
)
def get_width(self) -> int:
return self.width
def set_enabled(self, enabled: bool):
self.enabled = enabled
class TextRightItem(RightItem):
def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE):
self.text = text
class TextAction(ItemAction):
def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
self._text_source = text
self.color = color
self.font_size = font_size
font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, font_size).x
super().__init__(int(text_width + 20))
self._font = gui_app.font(FontWeight.NORMAL)
initial_text = _resolve_value(text, "")
text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x
super().__init__(int(text_width + TEXT_PADDING), enabled)
def draw(self, rect: rl.Rectangle) -> bool:
font = gui_app.font(FontWeight.NORMAL)
text_size = measure_text_cached(font, self.text, self.font_size)
@property
def text(self):
return _resolve_value(self._text_source, "Error")
def _render(self, rect: rl.Rectangle) -> bool:
current_text = self.text
text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE)
# Center the text in the allocated rectangle
text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color)
rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color)
return False
def get_width(self) -> int:
return self.width
def set_text(self, text: str):
self.text = text
font = gui_app.font(FontWeight.NORMAL)
text_width = measure_text_cached(font, text, self.font_size).x
self.width = int(text_width + 20)
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
return int(text_width + TEXT_PADDING)
@dataclass
class ListItem:
title: str
icon: str | None = None
description: str | None = None
description_visible: bool = False
rect: "rl.Rectangle | None" = None
callback: Callable | None = None
right_item: RightItem | None = None
class DualButtonAction(ItemAction):
def __init__(self, left_text: str, right_text: str, left_callback: Callable = None,
right_callback: Callable = None, enabled: bool | Callable[[], bool] = True):
super().__init__(width=0, enabled=enabled) # Width 0 means use full width
self.left_text, self.right_text = left_text, right_text
self.left_callback, self.right_callback = left_callback, right_callback
# Cached properties for performance
_wrapped_description: str | None = None
_description_height: float = 0
def _render(self, rect: rl.Rectangle) -> bool:
button_spacing = 30
button_height = 120
button_width = (rect.width - button_spacing) / 2
button_y = rect.y + (rect.height - button_height) / 2
def get_right_item(self) -> RightItem | None:
return self.right_item
left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
def get_item_height(self, font: rl.Font, max_width: int) -> float:
if self.description_visible and self.description:
if not self._wrapped_description:
wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width)
self._wrapped_description = "\n".join(wrapped_lines)
self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding
return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING
return ITEM_BASE_HEIGHT
left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) == 1
right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) == 1
def get_content_width(self, total_width: int) -> int:
if self.right_item:
return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING
return total_width
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.right_item:
return rl.Rectangle(0, 0, 0, 0)
right_width = self.right_item.get_width()
right_x = item_rect.x + item_rect.width - right_width
right_y = item_rect.y
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
if left_clicked and self.left_callback:
self.left_callback()
return True
if right_clicked and self.right_callback:
self.right_callback()
return True
return False
class ListView:
def __init__(self, items: list[ListItem]):
self._items: list[ListItem] = items
self._last_dim: tuple[float, float] = (0, 0)
self.scroll_panel = GuiScrollPanel()
class MultipleButtonAction(ItemAction):
def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None):
super().__init__(width=len(buttons) * (button_width + 20), enabled=True)
self.buttons = buttons
self.button_width = button_width
self.selected_button = selected_index
self.callback = callback
self._font = gui_app.font(FontWeight.MEDIUM)
self._font_normal = gui_app.font(FontWeight.NORMAL)
def _render(self, rect: rl.Rectangle) -> bool:
spacing = 20
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
clicked = -1
# Interaction state
self._hovered_item: int = -1
self._last_mouse_pos = rl.Vector2(0, 0)
for i, text in enumerate(self.buttons):
button_x = rect.x + i * (self.button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
self._total_height: float = 0
self._visible_range = (0, 0)
# Check button state
mouse_pos = rl.get_mouse_position()
is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect)
is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._is_pressed
is_selected = i == self.selected_button
def invalid_height_cache(self):
self._last_dim = (0, 0)
# Button colors
if is_selected:
bg_color = rl.Color(51, 171, 76, 255) # Green
elif is_pressed:
bg_color = rl.Color(74, 74, 74, 255) # Dark gray
else:
bg_color = rl.Color(57, 57, 57, 255) # Gray
def render(self, rect: rl.Rectangle):
if self._last_dim != (rect.width, rect.height):
self._update_item_rects(rect)
self._last_dim = (rect.width, rect.height)
# Draw button
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
# Update layout and handle scrolling
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height)
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect)
# Draw text
text_size = measure_text_cached(self._font, text, 40)
text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, rl.Color(228, 228, 228, 255))
# Handle mouse interaction
if self.scroll_panel.is_click_valid():
self._handle_mouse_interaction(rect, scroll_offset)
# Handle click
if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._is_pressed:
clicked = i
# Set scissor mode for clipping
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
if clicked >= 0:
self.selected_button = clicked
if self.callback:
self.callback(clicked)
return True
return False
# Calculate visible range for performance
self._calculate_visible_range(rect, -scroll_offset.y)
# Render only visible items
for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))):
item = self._items[i]
if item.rect:
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
self._render_item(item, adjusted_rect, i)
class ListItem(Widget):
def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
description_visible: bool = False, callback: Callable | None = None,
action_item: ItemAction | None = None):
super().__init__()
self.title = title
self.icon = icon
self.description = description
self.description_visible = description_visible
self.callback = callback
self.action_item = action_item
if i != len(self._items) - 1:
rl.draw_line_ex(
rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1),
rl.Vector2(
adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1
),
1.0,
LINE_COLOR,
)
rl.end_scissor_mode()
self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
self._font = gui_app.font(FontWeight.NORMAL)
def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int):
content_x = rect.x + ITEM_PADDING
# Cached properties for performance
self._prev_max_width: int = 0
self._wrapped_description: str | None = None
self._prev_description: str | None = None
self._description_height: float = 0
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
if self.action_item:
self.action_item.set_touch_valid_callback(touch_callback)
def set_parent_rect(self, parent_rect: rl.Rectangle):
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if not self.is_visible:
return
# Check not in action rect
if self.action_item:
action_rect = self.get_right_item_rect(self._rect)
if rl.check_collision_point_rec(mouse_pos, action_rect):
# Click was on right item, don't toggle description
return
if self.description:
self.description_visible = not self.description_visible
content_width = self.get_content_width(int(self._rect.width - ITEM_PADDING * 2))
self._rect.height = self.get_item_height(self._font, content_width)
def _render(self, _):
if not self.is_visible:
return
# Don't draw items that are not in parent's viewport
if ((self._rect.y + self.rect.height) <= self._parent_rect.y or
self._rect.y >= (self._parent_rect.y + self._parent_rect.height)):
return
content_x = self._rect.x + ITEM_PADDING
text_x = content_x
# Calculate available width for main content
content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2))
# Only draw title and icon for items that have them
if self.title:
# Draw icon if present
if self.icon:
icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE)
text_x += ICON_SIZE + ITEM_PADDING
# Draw icon if present
if item.icon:
icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(
icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE
)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE)
item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible (adjust width for right item)
if item.description_visible and item._wrapped_description:
desc_y = rect.y + ITEM_DESC_V_OFFSET
desc_max_width = int(content_width - (text_x - content_x))
# Re-wrap description if needed due to right item
if (item.right_item and item.description) and not item._wrapped_description:
wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width)
item._wrapped_description = "\n".join(wrapped_lines)
# Draw main text
text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible
current_description = self.get_description()
if self.description_visible and current_description and self._wrapped_description:
rl.draw_text_ex(
self._font_normal,
item._wrapped_description,
rl.Vector2(text_x, desc_y),
self._font,
self._wrapped_description,
rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
)
# Draw right item if present
if item.right_item:
right_rect = item.get_right_item_rect(rect)
# Adjust for scroll offset
right_rect.y = right_rect.y
if item.right_item.draw(right_rect):
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
right_rect.y = self._rect.y
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if item.callback:
item.callback()
if self.callback:
self.callback()
def _update_item_rects(self, container_rect: rl.Rectangle) -> None:
current_y: float = 0.0
self._total_height = 0
def get_description(self):
return _resolve_value(self.description, None)
for item in self._items:
content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2))
item_height = item.get_item_height(self._font_normal, content_width)
item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height)
current_y += item_height
self._total_height += item_height
def get_item_height(self, font: rl.Font, max_width: int) -> float:
if not self.is_visible:
return 0
def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float):
if not self._items:
self._visible_range = (0, 0)
return
current_description = self.get_description()
if self.description_visible and current_description:
if (
not self._wrapped_description
or current_description != self._prev_description
or max_width != self._prev_max_width
):
self._prev_max_width = max_width
self._prev_description = current_description
visible_top = scroll_offset
visible_bottom = scroll_offset + rect.height
wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width)
self._wrapped_description = "\n".join(wrapped_lines)
self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10
return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING
return ITEM_BASE_HEIGHT
start_idx = 0
end_idx = len(self._items)
def get_content_width(self, total_width: int) -> int:
if self.action_item and self.action_item.rect.width > 0:
return total_width - int(self.action_item.rect.width) - RIGHT_ITEM_PADDING
return total_width
# Find first visible item
for i, item in enumerate(self._items):
if item.rect and item.rect.y + item.rect.height >= visible_top:
start_idx = max(0, i - 1)
break
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
# Find last visible item
for i in range(start_idx, len(self._items)):
item = self._items[i]
if item.rect and item.rect.y > visible_bottom:
end_idx = min(len(self._items), i + 2)
break
right_width = self.action_item.rect.width
if right_width == 0: # Full width action (like DualButtonAction)
return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y,
item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT)
self._visible_range = (start_idx, end_idx)
def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
mouse_pos = rl.get_mouse_position()
self._hovered_item = -1
if not rl.check_collision_point_rec(mouse_pos, rect):
return
content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y
for i, item in enumerate(self._items):
if item.rect:
# Check if mouse is within this item's bounds in content space
if (
mouse_pos.x >= rect.x
and mouse_pos.x <= rect.x + rect.width
and content_mouse_y >= item.rect.y
and content_mouse_y <= item.rect.y + item.rect.height
):
item_screen_y = item.rect.y + scroll_offset.y
if item_screen_y < rect.height and item_screen_y + item.rect.height > 0:
self._hovered_item = i
break
# Handle click on main item (not right item)
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0:
item = self._items[self._hovered_item]
# Check if click was on right item area
if item.right_item and item.rect:
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
right_rect = item.get_right_item_rect(adjusted_rect)
if rl.check_collision_point_rec(mouse_pos, right_rect):
# Click was handled by right item, don't process main item click
return
# Toggle description visibility if item has description
if item.description:
item.description_visible = not item.description_visible
# Force layout update when description visibility changes
self._last_dim = (0, 0)
# Call item callback
if item.callback:
item.callback()
right_x = item_rect.x + item_rect.width - right_width
right_y = item_rect.y
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
# Factory functions
@@ -363,18 +338,31 @@ def simple_item(title: str, callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback)
def toggle_item(
title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = ""
) -> ListItem:
toggle = ToggleRightItem(initial_state=initial_state)
return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback)
def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
action = ToggleAction(initial_state=initial_state, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback)
def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem:
button = ButtonRightItem(text=button_text)
return ListItem(title=title, description=description, right_item=button, callback=callback)
def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = ButtonAction(text=button_text, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem:
text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255))
return ListItem(title=title, description=description, right_item=text_item, callback=callback)
def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
def dual_button_item(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None,
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
return ListItem(title="", description=description, action_item=action)
def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int,
button_width: int = BUTTON_WIDTH, callback: Callable = None, icon: str = ""):
action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback)
return ListItem(title=title, description=description, icon=icon, action_item=action)
+9 -20
View File
@@ -1,11 +1,12 @@
import pyray as rl
from collections import deque
from enum import IntEnum
# Scroll constants for smooth scrolling behavior
MOUSE_WHEEL_SCROLL_SPEED = 30
INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down
MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia
DRAG_THRESHOLD = 5 # Pixels of movement to consider it a drag, not a click
DRAG_THRESHOLD = 12 # Pixels of movement to consider it a drag, not a click
BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries
BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce
MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect
@@ -31,8 +32,7 @@ class GuiScrollPanel:
self._velocity_y = 0.0 # Velocity for inertia
self._is_dragging: bool = False
self._bounce_offset: float = 0.0
self._last_frame_time = rl.get_time()
self._velocity_history: list[float] = []
self._velocity_history: deque[float] = deque(maxlen=VELOCITY_HISTORY_SIZE)
self._last_drag_time: float = 0.0
self._content_rect: rl.Rectangle | None = None
self._bounds_rect: rl.Rectangle | None = None
@@ -44,11 +44,6 @@ class GuiScrollPanel:
# Calculate time delta
current_time = rl.get_time()
delta_time = current_time - self._last_frame_time
self._last_frame_time = current_time
# Prevent large jumps
delta_time = min(delta_time, 0.05)
mouse_pos = rl.get_mouse_position()
max_scroll_y = max(content.height - bounds.height, 0)
@@ -63,13 +58,15 @@ class GuiScrollPanel:
if mouse_pos.x >= scrollbar_x:
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR
# TODO: hacky
# when clicking while moving, go straight into dragging
self._is_dragging = abs(self._velocity_y) > MIN_VELOCITY
self._last_mouse_y = mouse_pos.y
self._start_mouse_y = mouse_pos.y
self._last_drag_time = current_time
self._velocity_history = []
self._velocity_history.clear()
self._velocity_y = 0.0
self._bounce_offset = 0.0
self._is_dragging = False
# Handle active dragging
if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
@@ -82,9 +79,6 @@ class GuiScrollPanel:
drag_velocity = delta_y / time_since_last_drag / 60.0
self._velocity_history.append(drag_velocity)
if len(self._velocity_history) > VELOCITY_HISTORY_SIZE:
self._velocity_history.pop(0)
self._last_drag_time = current_time
# Detect actual dragging
@@ -175,13 +169,8 @@ class GuiScrollPanel:
return self._offset
def is_click_valid(self) -> bool:
# Check if this is a click rather than a drag
return (
self._scroll_state == ScrollState.IDLE
and not self._is_dragging
and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
)
def is_touch_valid(self):
return not self._is_dragging
def get_normalized_scroll_position(self) -> float:
"""Returns the current scroll position as a value from 0.0 to 1.0"""
+74
View File
@@ -0,0 +1,74 @@
import pyray as rl
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
ITEM_SPACING = 40
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING * 2, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
self._line_separator = line_separator
self._pad_end = pad_end
self.scroll_panel = GuiScrollPanel()
for item in items:
self.add_widget(item)
def add_widget(self, item: Widget) -> None:
if self._line_separator and len(self._items) > 0:
self._items.append(LineSeparator())
self._items.append(item)
item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
def _render(self, _):
# TODO: don't draw items that are not in the viewport
visible_items = [item for item in self._items if item.is_visible]
content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
if not self._pad_end:
content_height -= self._spacing
scroll = self.scroll_panel.handle_scroll(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
cur_height = 0
for idx, item in enumerate(visible_items):
if not item.is_visible:
continue
# Nicely lay out items vertically
x = self._rect.x
y = self._rect.y + cur_height + self._spacing * (idx != 0)
cur_height += item.rect.height + self._spacing * (idx != 0)
# Consider scroll
x += scroll.x
y += scroll.y
# Update item state
item.set_position(x, y)
item.set_parent_rect(self._rect)
item.render()
rl.end_scissor_mode()
+12 -6
View File
@@ -1,13 +1,20 @@
import platform
import pyray as rl
import numpy as np
from typing import Any
MAX_GRADIENT_COLORS = 15
FRAGMENT_SHADER = """
VERSION = """
#version 300 es
precision mediump float;
precision highp float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
@@ -105,14 +112,13 @@ void main() {
vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor;
finalColor = vec4(color.rgb, color.a * alpha);
} else {
finalColor = vec4(0.0);
discard;
}
}
"""
# Default vertex shader
VERTEX_SHADER = """
#version 300 es
VERTEX_SHADER = VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
@@ -124,7 +130,6 @@ void main() {
}
"""
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
@@ -244,6 +249,7 @@ def _configure_shader_color(state, color, gradient, clipped_rect, original_rect)
state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0]
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, color=None, gradient=None):
"""
Draw a complex polygon using shader-based even-odd fill rule
+12 -15
View File
@@ -1,4 +1,5 @@
import pyray as rl
from openpilot.system.ui.lib.widget import Widget
ON_COLOR = rl.Color(51, 171, 76, 255)
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
@@ -11,24 +12,23 @@ BG_HEIGHT = 60
ANIMATION_SPEED = 8.0
class Toggle:
class Toggle(Widget):
def __init__(self, initial_state=False):
super().__init__()
self._state = initial_state
self._enabled = True
self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT)
self._progress = 1.0 if initial_state else 0.0
self._target = self._progress
def handle_input(self):
if not self._enabled:
return 0
def set_rect(self, rect: rl.Rectangle):
self._rect = rl.Rectangle(rect.x, rect.y, WIDTH, HEIGHT)
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect):
self._state = not self._state
self._target = 1.0 if self._state else 0.0
return 1
return 0
def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if not self._enabled:
return
self._state = not self._state
self._target = 1.0 if self._state else 0.0
def get_state(self):
return self._state
@@ -49,8 +49,7 @@ class Toggle:
self._progress += delta if self._progress < self._target else -delta
self._progress = max(0.0, min(1.0, self._progress))
def render(self, rect: rl.Rectangle):
self._rect.x, self._rect.y = rect.x, rect.y
def _render(self, rect: rl.Rectangle):
self.update()
if self._enabled:
@@ -69,7 +68,5 @@ class Toggle:
knob_y = self._rect.y + HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
return self.handle_input()
def _blend_color(self, c1, c2, t):
return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255)
+96
View File
@@ -0,0 +1,96 @@
import abc
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
class DialogResult(IntEnum):
CANCEL = 0
CONFIRM = 1
NO_ACTION = -1
class Widget(abc.ABC):
def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._is_pressed = False
self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
"""Set a callback to determine if the widget can be clicked."""
self._touch_valid_callback = touch_callback
def _touch_valid(self) -> bool:
"""Check if the widget can be touched."""
return self._touch_valid_callback() if self._touch_valid_callback else True
@property
def is_visible(self) -> bool:
return self._is_visible() if callable(self._is_visible) else self._is_visible
@property
def rect(self) -> rl.Rectangle:
return self._rect
def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible
def set_rect(self, rect: rl.Rectangle) -> None:
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
self._rect = rect
if changed:
self._update_layout_rects()
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
"""Can be used like size hint in QT"""
self._parent_rect = parent_rect
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect.x, self._rect.y = x, y
if changed:
self._update_layout_rects()
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
if rect is not None:
self.set_rect(rect)
self._update_state()
if not self.is_visible:
return None
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
mouse_pos = rl.get_mouse_position()
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._touch_valid():
if rl.check_collision_point_rec(mouse_pos, self._rect):
self._is_pressed = True
elif not self._touch_valid():
self._is_pressed = False
elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
if self._is_pressed and rl.check_collision_point_rec(mouse_pos, self._rect):
self._handle_mouse_release(mouse_pos)
self._is_pressed = False
return ret
@abc.abstractmethod
def _render(self, rect: rl.Rectangle) -> bool | int | None:
"""Render the widget within the given rectangle."""
def _update_state(self):
"""Optionally update the widget's non-layout state. This is called before rendering."""
def _update_layout_rects(self) -> None:
"""Optionally update any layout rects on Widget rect change."""
def _handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool:
"""Optionally handle mouse release events."""
return False
+5 -1
View File
@@ -13,6 +13,7 @@ from dbus_next.aio import MessageBus
from dbus_next import BusType, Variant, Message
from dbus_next.errors import DBusError
from dbus_next.constants import MessageType
try:
from openpilot.common.params import Params
except ImportError:
@@ -38,6 +39,7 @@ NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "12345678"
# NetworkManager device states
class NMDeviceState(IntEnum):
DISCONNECTED = 30
@@ -46,6 +48,7 @@ class NMDeviceState(IntEnum):
IP_CONFIG = 70
ACTIVATED = 100
class SecurityType(IntEnum):
OPEN = 0
WPA = 1
@@ -53,6 +56,7 @@ class SecurityType(IntEnum):
WPA3 = 3
UNSUPPORTED = 4
@dataclass
class NetworkInfo:
ssid: str
@@ -227,7 +231,7 @@ class WifiManager:
except Exception as e:
self._current_connection_ssid = None
cloudlog.error(f"Error connecting to network: {e}")
# Notify UI of failure
# Notify UI of failure
if self.callbacks.connection_failed:
self.callbacks.connection_failed(ssid, str(e))
-58
View File
@@ -1,58 +0,0 @@
import threading
import time
import os
from typing import Generic, Protocol, TypeVar
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.application import gui_app
class RendererProtocol(Protocol):
def render(self): ...
R = TypeVar("R", bound=RendererProtocol)
class BaseWindow(Generic[R]):
def __init__(self, title: str):
self._title = title
self._renderer: R | None = None
self._stop_event = threading.Event()
self._thread = threading.Thread(target=self._run)
self._thread.start()
# wait for the renderer to be initialized
while self._renderer is None and self._thread.is_alive():
time.sleep(0.01)
def _create_renderer(self) -> R:
raise NotImplementedError()
def _run(self):
if os.getenv("CI") is not None:
return
gui_app.init_window(self._title)
self._renderer = self._create_renderer()
try:
for _ in gui_app.render():
if self._stop_event.is_set():
break
self._renderer.render()
finally:
gui_app.close()
def __enter__(self):
return self
def close(self):
if self._thread.is_alive():
self._stop_event.set()
self._thread.join(timeout=2.0)
if self._thread.is_alive():
cloudlog.warning(f"Failed to join {self._title} thread")
def __del__(self):
self.close()
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
+56 -40
View File
@@ -1,6 +1,7 @@
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
if not word:
return []
@@ -39,49 +40,64 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
if not text or max_width <= 0:
return []
words = text.split()
if not words:
return []
# Split text by newlines first to preserve explicit line breaks
paragraphs = text.split('\n')
all_lines: list[str] = []
lines: list[str] = []
current_line: list[str] = []
current_width = 0
space_width = int(measure_text_cached(font, " ", font_size).x)
for word in words:
word_width = int(measure_text_cached(font, word, font_size).x)
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
current_width = 0
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width))
for paragraph in paragraphs:
# Handle empty paragraphs (preserve empty lines)
if not paragraph.strip():
all_lines.append("")
continue
# Calculate width if we add this word
needed_width = current_width
if current_line: # Need space before word
needed_width += space_width
needed_width += word_width
# Process each paragraph separately
words = paragraph.split()
if not words:
all_lines.append("")
continue
# Check if word fits on current line
if needed_width <= max_width:
current_line.append(word)
current_width = needed_width
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
current_width = word_width
lines: list[str] = []
current_line: list[str] = []
current_width = 0
space_width = int(measure_text_cached(font, " ", font_size).x)
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
for word in words:
word_width = int(measure_text_cached(font, word, font_size).x)
return lines
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
current_width = 0
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width))
continue
# Calculate width if we add this word
needed_width = current_width
if current_line: # Need space before word
needed_width += space_width
needed_width += word_width
# Check if word fits on current line
if needed_width <= max_width:
current_line.append(word)
current_width = needed_width
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
current_width = word_width
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
# Add all lines from this paragraph
all_lines.extend(lines)
return all_lines
+5 -3
View File
@@ -9,6 +9,7 @@ from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.label import gui_label, gui_text_box
from openpilot.system.ui.lib.widget import Widget
NVME = "/dev/nvme0n1"
USERDATA = "/dev/disk/by-partlabel/userdata"
@@ -27,8 +28,9 @@ class ResetState(IntEnum):
FAILED = 3
class Reset:
class Reset(Widget):
def __init__(self, mode):
super().__init__()
self.mode = mode
self.reset_state = ResetState.NONE
@@ -54,7 +56,7 @@ class Reset:
self.reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def render(self, rect: rl.Rectangle):
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100)
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
@@ -76,7 +78,7 @@ class Reset:
if self.reset_state != ResetState.FAILED:
if gui_button(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height),
"Confirm", button_style=ButtonStyle.PRIMARY):
"Confirm", button_style=ButtonStyle.PRIMARY):
self.confirm()
return True
+16 -17
View File
@@ -12,6 +12,7 @@ from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.label import gui_label, gui_text_box
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManagerWrapper
from openpilot.system.ui.widgets.keyboard import Keyboard
@@ -39,8 +40,9 @@ class SetupState(IntEnum):
DOWNLOAD_FAILED = 6
class Setup:
class Setup(Widget):
def __init__(self):
super().__init__()
self.state = SetupState.GETTING_STARTED
self.network_check_thread = None
self.network_connected = threading.Event()
@@ -67,7 +69,7 @@ class Setup:
except (FileNotFoundError, ValueError):
self.state = SetupState.LOW_VOLTAGE
def render(self, rect: rl.Rectangle):
def _render(self, rect: rl.Rectangle):
if self.state == SetupState.LOW_VOLTAGE:
self.render_low_voltage(rect)
elif self.state == SetupState.GETTING_STARTED:
@@ -116,7 +118,6 @@ class Setup:
if ret:
self.state = SetupState.NETWORK_SETUP
self.wifi_manager.request_scan()
self.start_network_check()
def check_network_connectivity(self):
@@ -144,10 +145,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)
@@ -256,18 +253,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
+41 -42
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
import pyray as rl
import threading
import time
import select
import sys
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.window import BaseWindow
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.text import wrap_text
from openpilot.system.ui.sunnypilot.lib.application import gui_app_sp
@@ -25,41 +25,36 @@ def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value)
class SpinnerRenderer:
class Spinner(Widget):
def __init__(self):
super().__init__()
self._comma_texture = gui_app_sp.sp_texture("images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE)
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
self._rotation = 0.0
self._progress: int | None = None
self._wrapped_lines: list[str] = []
self._lock = threading.Lock()
def set_text(self, text: str) -> None:
with self._lock:
if text.isdigit():
self._progress = clamp(int(text), 0, 100)
self._wrapped_lines = []
else:
self._progress = None
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
if text.isdigit():
self._progress = clamp(int(text), 0, 100)
self._wrapped_lines = []
else:
self._progress = None
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
def render(self):
with self._lock:
progress = self._progress
wrapped_lines = self._wrapped_lines
if wrapped_lines:
def _render(self, rect: rl.Rectangle):
if self._wrapped_lines:
# Calculate total height required for spinner and text
spacing = 50
total_height = TEXTURE_SIZE + spacing + len(wrapped_lines) * LINE_HEIGHT
center_y = (gui_app.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT
center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
else:
# Center spinner vertically
spacing = 150
center_y = gui_app.height / 2.0
center_y = rect.height / 2.0
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
center = rl.Vector2(gui_app.width / 2.0, center_y)
center = rl.Vector2(rect.width / 2.0, center_y)
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
@@ -73,38 +68,42 @@ class SpinnerRenderer:
rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE)
# Display the progress bar or text based on user input
if progress is not None:
if self._progress is not None:
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
bar.width *= progress / 100.0
bar.width *= self._progress / 100.0
rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
elif wrapped_lines:
for i, line in enumerate(wrapped_lines):
elif self._wrapped_lines:
for i, line in enumerate(self._wrapped_lines):
text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE)
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
FONT_SIZE, 0.0, rl.WHITE)
class Spinner(BaseWindow[SpinnerRenderer]):
def __init__(self):
super().__init__("Spinner")
def _create_renderer(self):
return SpinnerRenderer()
def update(self, spinner_text: str):
if self._renderer is not None:
self._renderer.set_text(spinner_text)
def update_progress(self, cur: float, total: float):
self.update(str(round(100 * cur / total)))
def _read_stdin():
"""Non-blocking read of available lines from stdin."""
lines = []
while True:
rlist, _, _ = select.select([sys.stdin], [], [], 0.0)
if not rlist:
break
line = sys.stdin.readline().strip()
if line == "":
break
lines.append(line)
return lines
def main():
with Spinner() as s:
s.update("Spinner text")
time.sleep(5)
gui_app.init_window("Spinner")
spinner = Spinner()
for _ in gui_app.render():
text_list = _read_stdin()
if text_list:
spinner.set_text(text_list[-1])
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if __name__ == "__main__":
+12 -20
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
import re
import time
import sys
import pyray as rl
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.window import BaseWindow
from openpilot.system.ui.lib.widget import Widget
MARGIN = 50
SPACING = 40
@@ -18,6 +18,7 @@ BUTTON_SIZE = rl.Vector2(310, 160)
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
The text is long enough to demonstrate scrolling and word wrapping.""" * 30
def wrap_text(text, font_size, max_width):
lines = []
font = gui_app.font()
@@ -46,15 +47,16 @@ def wrap_text(text, font_size, max_width):
return lines
class TextWindowRenderer:
class TextWindow(Widget):
def __init__(self, text: str):
super().__init__()
self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2)
self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20)
self._content_rect = rl.Rectangle(0, 0, self._textarea_rect.width - 20, len(self._wrapped_lines) * LINE_HEIGHT)
self._scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True)
self._scroll_panel._offset.y = -max(self._content_rect.height - self._textarea_rect.height, 0)
def render(self):
def _render(self, rect: rl.Rectangle):
scroll = self._scroll_panel.handle_scroll(self._textarea_rect, self._content_rect)
rl.begin_scissor_mode(int(self._textarea_rect.x), int(self._textarea_rect.y), int(self._textarea_rect.width), int(self._textarea_rect.height))
for i, line in enumerate(self._wrapped_lines):
@@ -64,7 +66,7 @@ class TextWindowRenderer:
rl.draw_text_ex(gui_app.font(), line, position, FONT_SIZE, 0, rl.WHITE)
rl.end_scissor_mode()
button_bounds = rl.Rectangle(gui_app.width - MARGIN - BUTTON_SIZE.x - SPACING, gui_app.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y)
button_bounds = rl.Rectangle(rect.width - MARGIN - BUTTON_SIZE.x - SPACING, rect.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y)
ret = gui_button(button_bounds, "Exit" if PC else "Reboot", button_style=ButtonStyle.TRANSPARENT)
if ret:
if PC:
@@ -74,19 +76,9 @@ class TextWindowRenderer:
return ret
class TextWindow(BaseWindow[TextWindowRenderer]):
def __init__(self, text: str):
self._text = text
super().__init__("Text")
def _create_renderer(self):
return TextWindowRenderer(self._text)
def wait_for_exit(self):
while self._thread.is_alive():
time.sleep(0.01)
if __name__ == "__main__":
with TextWindow(DEMO_TEXT):
time.sleep(30)
text = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT
gui_app.init_window("Text Viewer")
text_window = TextWindow(text)
for _ in gui_app.render():
text_window.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+22 -23
View File
@@ -10,9 +10,9 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.label import gui_text_box, gui_label
from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.widgets.network import WifiManagerUI
# Constants
MARGIN = 50
BUTTON_HEIGHT = 160
@@ -31,8 +31,9 @@ class Screen(IntEnum):
PROGRESS = 2
class Updater:
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
@@ -60,7 +61,7 @@ class Updater:
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
text=True, bufsize=1, universal_newlines=True)
for line in self.process.stdout:
parts = line.strip().split(":")
@@ -78,21 +79,21 @@ class Updater:
self.progress_text = "Update failed"
self.show_reboot_button = True
def render_prompt_screen(self):
def render_prompt_screen(self, rect: rl.Rectangle):
# Title
title_rect = rl.Rectangle(MARGIN + 50, 250, gui_app.width - MARGIN * 2 - 100, TITLE_FONT_SIZE)
title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE)
gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
# Description
desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
"The download size is approximately 1GB.")
"The download size is approximately 1GB.")
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE + 75, gui_app.width - MARGIN * 2 - 100, BODY_FONT_SIZE * 3)
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * 3)
gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
# Buttons at the bottom
button_y = gui_app.height - MARGIN - BUTTON_HEIGHT
button_width = (gui_app.width - MARGIN * 3) // 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
button_width = (rect.width - MARGIN * 3) // 2
# WiFi button
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
@@ -106,24 +107,22 @@ class Updater:
self.install_update()
return # Return to avoid further processing after action
def render_wifi_screen(self):
def render_wifi_screen(self, rect: rl.Rectangle):
# 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)
wifi_rect = rl.Rectangle(MARGIN + 50, MARGIN, rect.width - MARGIN * 2 - 100, rect.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)
back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
if gui_button(back_button_rect, "Back"):
self.current_screen = Screen.PROMPT
return # Return to avoid processing other interactions after screen change
def render_progress_screen(self):
title_rect = rl.Rectangle(MARGIN + 100, 330, gui_app.width - MARGIN * 2 - 200, 100)
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
# Progress bar
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, gui_app.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
# Calculate the width of the progress chunk
@@ -134,19 +133,19 @@ class Updater:
# Show reboot button if needed
if self.show_reboot_button:
reboot_rect = rl.Rectangle(MARGIN + 100, gui_app.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
if gui_button(reboot_rect, "Reboot"):
# Return True to signal main loop to exit before rebooting
HARDWARE.reboot()
return
def render(self):
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen()
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self.render_wifi_screen()
self.render_wifi_screen(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen()
self.render_progress_screen(rect)
def main():
@@ -161,7 +160,7 @@ def main():
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for _ in gui_app.render():
updater.render()
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
# Make sure we clean up even if there's an error
gui_app.close()
+23 -12
View File
@@ -1,9 +1,9 @@
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.label import gui_text_box
from openpilot.system.ui.lib.widget import DialogResult
# Constants for dialog dimensions and styling
DIALOG_WIDTH = 1520
DIALOG_HEIGHT = 600
BUTTON_HEIGHT = 160
@@ -12,7 +12,7 @@ TEXT_AREA_HEIGHT_REDUCTION = 200
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
def confirm_dialog(message: str, confirm_text: str, cancel_text: str = "Cancel") -> int:
def confirm_dialog(message: str, confirm_text: str, cancel_text: str = "Cancel") -> DialogResult:
dialog_x = (gui_app.width - DIALOG_WIDTH) / 2
dialog_y = (gui_app.height - DIALOG_HEIGHT) / 2
dialog_rect = rl.Rectangle(dialog_x, dialog_y, DIALOG_WIDTH, DIALOG_HEIGHT)
@@ -30,28 +30,39 @@ def confirm_dialog(message: str, confirm_text: str, cancel_text: str = "Cancel")
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
# Draw the message in the dialog, centered
text_rect = rl.Rectangle(dialog_rect.x, dialog_rect.y, dialog_rect.width, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION)
text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y, dialog_rect.width - 2 * MARGIN, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION)
gui_text_box(
text_rect,
message,
font_size=88,
font_size=70,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
font_weight=FontWeight.BOLD,
)
# Initialize result; -1 means no action taken yet
result = -1
result = DialogResult.NO_ACTION
# Check for keyboard input for accessibility
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
result = 1 # Confirm
result = DialogResult.CONFIRM
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
result = 0 # Cancel
result = DialogResult.CANCEL
# Check for button clicks
if gui_button(yes_button, confirm_text, button_style=ButtonStyle.PRIMARY):
result = 1 # Confirm
if gui_button(no_button, cancel_text):
result = 0 # Cancel
if cancel_text:
if gui_button(yes_button, confirm_text, button_style=ButtonStyle.PRIMARY):
result = DialogResult.CONFIRM
if gui_button(no_button, cancel_text):
result = DialogResult.CANCEL
else:
centered_button_x = dialog_rect.x + (dialog_rect.width - button_width) / 2
centered_yes_button = rl.Rectangle(centered_button_x, button_y, button_width, BUTTON_HEIGHT)
if gui_button(centered_yes_button, confirm_text, button_style=ButtonStyle.PRIMARY):
result = DialogResult.CONFIRM
return result
def alert_dialog(message: str, button_text: str = "OK") -> DialogResult:
return confirm_dialog(message, button_text, cancel_text="")
+194
View File
@@ -0,0 +1,194 @@
import re
import pyray as rl
from dataclasses import dataclass
from enum import Enum
from typing import Any
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.widget import Widget, DialogResult
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
class ElementType(Enum):
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
H5 = "h5"
H6 = "h6"
P = "p"
BR = "br"
@dataclass
class HtmlElement:
type: ElementType
content: str
font_size: int
font_weight: FontWeight
color: rl.Color
margin_top: int
margin_bottom: int
line_height: float = 1.2
class HtmlRenderer(Widget):
def __init__(self, file_path: str):
self.elements: list[HtmlElement] = []
self._normal_font = gui_app.font(FontWeight.NORMAL)
self._bold_font = gui_app.font(FontWeight.BOLD)
self._scroll_panel = GuiScrollPanel()
self.styles: dict[ElementType, dict[str, Any]] = {
ElementType.H1: {"size": 68, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 16},
ElementType.H2: {"size": 60, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 24, "margin_bottom": 12},
ElementType.H3: {"size": 52, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 20, "margin_bottom": 10},
ElementType.H4: {"size": 48, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 16, "margin_bottom": 8},
ElementType.H5: {"size": 44, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 12, "margin_bottom": 6},
ElementType.H6: {"size": 40, "weight": FontWeight.BOLD, "color": rl.BLACK, "margin_top": 10, "margin_bottom": 4},
ElementType.P: {"size": 38, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 8, "margin_bottom": 12},
ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "color": rl.BLACK, "margin_top": 0, "margin_bottom": 12},
}
self.parse_html_file(file_path)
def parse_html_file(self, file_path: str) -> None:
with open(file_path, encoding='utf-8') as file:
content = file.read()
self.parse_html_content(content)
def parse_html_content(self, html_content: str) -> None:
self.elements.clear()
# Remove HTML comments
html_content = re.sub(r'<!--.*?-->', '', html_content, flags=re.DOTALL)
# Remove DOCTYPE, html, head, body tags but keep their content
html_content = re.sub(r'<!DOCTYPE[^>]*>', '', html_content)
html_content = re.sub(r'</?(?:html|head|body)[^>]*>', '', html_content)
# Find all HTML elements
pattern = r'<(h[1-6]|p)(?:[^>]*)>(.*?)</\1>|<br\s*/?>'
matches = re.finditer(pattern, html_content, re.DOTALL | re.IGNORECASE)
for match in matches:
if match.group(0).lower().startswith('<br'):
# Handle <br> tags
self._add_element(ElementType.BR, "")
else:
tag = match.group(1).lower()
content = match.group(2).strip()
# Clean up content - remove extra whitespace
content = re.sub(r'\s+', ' ', content)
content = content.strip()
if content: # Only add non-empty elements
element_type = ElementType(tag)
self._add_element(element_type, content)
def _add_element(self, element_type: ElementType, content: str) -> None:
style = self.styles[element_type]
element = HtmlElement(
type=element_type,
content=content,
font_size=style["size"],
font_weight=style["weight"],
color=style["color"],
margin_top=style["margin_top"],
margin_bottom=style["margin_bottom"],
)
self.elements.append(element)
def _render(self, rect: rl.Rectangle):
margin = 50
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2))
button_height = 160
button_spacing = 20
scrollable_height = content_rect.height - button_height - button_spacing
scrollable_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, scrollable_height)
total_height = self.get_total_height(int(scrollable_rect.width))
scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height)
scroll_offset = self._scroll_panel.handle_scroll(scrollable_rect, scroll_content_rect)
rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height))
self._render_content(scrollable_rect, scroll_offset.y)
rl.end_scissor_mode()
button_width = (rect.width - 3 * 50) // 3
button_x = content_rect.x + (content_rect.width - button_width) / 2
button_y = content_rect.y + content_rect.height - button_height
button_rect = rl.Rectangle(button_x, button_y, button_width, button_height)
if gui_button(button_rect, "OK", button_style=ButtonStyle.PRIMARY) == 1:
return DialogResult.CONFIRM
return DialogResult.NO_ACTION
def _render_content(self, rect: rl.Rectangle, scroll_offset: float = 0) -> float:
current_y = rect.y + scroll_offset
padding = 20
content_width = rect.width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
current_y += element.margin_bottom
continue
current_y += element.margin_top
if current_y > rect.y + rect.height:
break
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width))
for line in wrapped_lines:
if current_y < rect.y - element.font_size:
current_y += element.font_size * element.line_height
continue
if current_y > rect.y + rect.height:
break
rl.draw_text_ex(font, line, rl.Vector2(rect.x + padding, current_y), element.font_size, 0, rl.WHITE)
current_y += element.font_size * element.line_height
# Apply bottom margin
current_y += element.margin_bottom
return current_y - rect.y - scroll_offset # Return total content height
def get_total_height(self, content_width: int) -> float:
total_height = 0.0
padding = 20
usable_width = content_width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
total_height += element.margin_bottom
continue
total_height += element.margin_top
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width))
for _ in wrapped_lines:
total_height += element.font_size * element.line_height
total_height += element.margin_bottom
return total_height
def _get_font(self, weight: FontWeight):
if weight == FontWeight.BOLD:
return self._bold_font
return self._normal_font
+16 -7
View File
@@ -5,6 +5,7 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import ButtonStyle, gui_button
from openpilot.system.ui.lib.inputbox import InputBox
from openpilot.system.ui.lib.label import gui_label
from openpilot.system.ui.lib.widget import Widget
KEY_FONT_SIZE = 96
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
@@ -52,11 +53,14 @@ KEYBOARD_LAYOUTS = {
}
class Keyboard:
class Keyboard(Widget):
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False):
super().__init__()
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
@@ -67,7 +71,7 @@ class Keyboard:
# Backspace key repeat tracking
self._backspace_pressed: bool = False
self._backspace_press_time: float = 0.0
self._backspace_last_repeat:float = 0.0
self._backspace_last_repeat: float = 0.0
self._eye_open_texture = gui_app.texture("icons/eye_open.png", 81, 54)
self._eye_closed_texture = gui_app.texture("icons/eye_closed.png", 81, 54)
@@ -89,10 +93,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 +231,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()
+37 -31
View File
@@ -10,6 +10,7 @@ from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wifi_manager import NetworkInfo, WifiManagerCallbacks, WifiManagerWrapper, SecurityType
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog
from openpilot.system.ui.lib.widget import Widget
NM_DEVICE_STATE_NEED_AUTH = 60
MIN_PASSWORD_LENGTH = 8
@@ -24,35 +25,42 @@ STRENGTH_ICONS = [
"icons/wifi_strength_full.png",
]
@dataclass
class StateIdle:
action: Literal["idle"] = "idle"
@dataclass
class StateConnecting:
network: NetworkInfo
action: Literal["connecting"] = "connecting"
@dataclass
class StateNeedsAuth:
network: NetworkInfo
action: Literal["needs_auth"] = "needs_auth"
@dataclass
class StateShowForgetConfirm:
network: NetworkInfo
action: Literal["show_forget_confirm"] = "show_forget_confirm"
@dataclass
class StateForgetting:
network: NetworkInfo
action: Literal["forgetting"] = "forgetting"
UIState = StateIdle | StateConnecting | StateNeedsAuth | StateShowForgetConfirm | StateForgetting
class WifiManagerUI:
class WifiManagerUI(Widget):
def __init__(self, wifi_manager: WifiManagerWrapper):
super().__init__()
self.state: UIState = StateIdle()
self.btn_width: int = 200
self.scroll_panel = GuiScrollPanel()
@@ -64,17 +72,17 @@ class WifiManagerUI:
self.wifi_manager.set_callbacks(
WifiManagerCallbacks(
need_auth = self._on_need_auth,
activated = self._on_activated,
forgotten = self._on_forgotten,
networks_updated = self._on_network_updated,
connection_failed = self._on_connection_failed
need_auth=self._on_need_auth,
activated=self._on_activated,
forgotten=self._on_forgotten,
networks_updated=self._on_network_updated,
connection_failed=self._on_connection_failed
)
)
self.wifi_manager.start()
self.wifi_manager.connect()
def render(self, rect: rl.Rectangle):
def _render(self, rect: rl.Rectangle):
with self._lock:
if not self._networks:
gui_label(rect, "Scanning Wi-Fi networks...", 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
@@ -82,36 +90,34 @@ 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)
offset = self.scroll_panel.handle_scroll(rect, content_rect)
clicked = self.scroll_panel.is_click_valid()
clicked = self.scroll_panel.is_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
for i, network in enumerate(self._networks):
@@ -150,7 +156,8 @@ class WifiManagerUI:
else:
# If the network is saved, show the "Forget" button
if network.is_saved:
forget_btn_rect = rl.Rectangle(security_icon_rect.x - self.btn_width - spacing,
forget_btn_rect = rl.Rectangle(
security_icon_rect.x - self.btn_width - spacing,
rect.y + (ITEM_HEIGHT - 80) / 2,
self.btn_width,
80,
@@ -227,7 +234,6 @@ class WifiManagerUI:
self.state = StateIdle()
def main():
gui_app.init_window("Wi-Fi Manager")
wifi_manager = WifiManagerWrapper()
+51 -61
View File
@@ -1,81 +1,71 @@
import pyray as rl
from openpilot.system.ui.lib.application import FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle, TextAlignment
from openpilot.system.ui.lib.label import gui_label
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.widget import Widget
# Constants
MARGIN = 50
TITLE_FONT_SIZE = 70
ITEM_HEIGHT = 135
BUTTON_SPACING = 50
BUTTON_HEIGHT = 160
ITEM_SPACING = 50
LIST_ITEM_SPACING = 25
class MultiOptionDialog:
class MultiOptionDialog(Widget):
def __init__(self, title, options, current=""):
self._title = title
self._options = options
self._current = current if current in options else ""
self._selection = self._current
self._option_height = 80
self._padding = 20
self.scroll_panel = GuiScrollPanel()
super().__init__()
self.title = title
self.options = options
self.current = current
self.selection = current
self.scroll = GuiScrollPanel()
@property
def selection(self):
return self._selection
def _render(self, rect):
dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, rect.height - 2 * MARGIN)
rl.draw_rectangle_rounded(dialog_rect, 0.02, 20, rl.Color(30, 30, 30, 255))
def render(self, rect):
title_rect = rl.Rectangle(rect.x + self._padding, rect.y + self._padding, rect.width - 2 * self._padding, 70)
gui_label(title_rect, self._title, 70)
content_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - 2 * MARGIN)
options_y_start = rect.y + 120
options_height = len(self._options) * (self._option_height + 10)
options_rect = rl.Rectangle(rect.x + self._padding, options_y_start, rect.width - 2 * self._padding, options_height)
gui_label(rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, TITLE_FONT_SIZE), self.title, 70, font_weight=FontWeight.BOLD)
view_rect = rl.Rectangle(
rect.x + self._padding, options_y_start, rect.width - 2 * self._padding, rect.height - 200 - 2 * self._padding
)
# Options area
options_y = content_rect.y + TITLE_FONT_SIZE + ITEM_SPACING
options_h = content_rect.height - TITLE_FONT_SIZE - BUTTON_HEIGHT - 2 * ITEM_SPACING
view_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h)
content_h = len(self.options) * (ITEM_HEIGHT + 10)
list_content_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, content_h)
offset = self.scroll_panel.handle_scroll(view_rect, options_rect)
is_click_valid = self.scroll_panel.is_click_valid()
# Scroll and render options
offset = self.scroll.handle_scroll(view_rect, list_content_rect)
valid_click = self.scroll.is_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
rl.begin_scissor_mode(int(view_rect.x), int(view_rect.y), int(view_rect.width), int(view_rect.height))
rl.begin_scissor_mode(int(view_rect.x), int(options_y), int(view_rect.width), int(options_h))
for i, option in enumerate(self.options):
item_y = options_y + i * (ITEM_HEIGHT + LIST_ITEM_SPACING) + offset.y
item_rect = rl.Rectangle(view_rect.x, item_y, view_rect.width, ITEM_HEIGHT)
for i, option in enumerate(self._options):
y_pos = view_rect.y + i * (self._option_height + 10) + offset.y
item_rect = rl.Rectangle(view_rect.x, y_pos, view_rect.width, self._option_height)
if not rl.check_collision_recs(item_rect, view_rect):
continue
is_selected = option == self._selection
button_style = ButtonStyle.PRIMARY if is_selected else ButtonStyle.NORMAL
if gui_button(item_rect, option, button_style=button_style, text_alignment=TextAlignment.LEFT) and is_click_valid:
self._selection = option
if rl.check_collision_recs(item_rect, view_rect):
selected = option == self.selection
style = ButtonStyle.PRIMARY if selected else ButtonStyle.NORMAL
if gui_button(item_rect, option, button_style=style, text_alignment=TextAlignment.LEFT) and valid_click:
self.selection = option
rl.end_scissor_mode()
button_y = rect.y + rect.height - 80 - self._padding
button_width = (rect.width - 3 * self._padding) / 2
# Buttons
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
button_w = (content_rect.width - BUTTON_SPACING) / 2
cancel_rect = rl.Rectangle(rect.x + self._padding, button_y, button_width, 80)
if gui_button(cancel_rect, "Cancel"):
return 0 # Canceled
if gui_button(rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT), "Cancel"):
return 0
select_rect = rl.Rectangle(rect.x + 2 * self._padding + button_width, button_y, button_width, 80)
has_new_selection = self._selection != "" and self._selection != self._current
if gui_button(rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT),
"Select", is_enabled=self.selection != self.current, button_style=ButtonStyle.PRIMARY):
return 1
if gui_button(select_rect, "Select", is_enabled=has_new_selection, button_style=ButtonStyle.PRIMARY):
return 1 # Selected
return -1 # Still active
if __name__ == "__main__":
from openpilot.system.ui.lib.application import gui_app
gui_app.init_window("Multi Option Dialog Example")
options = [f"Option {i}" for i in range(1, 11)]
dialog = MultiOptionDialog("Choose an option", options, options[0])
for _ in gui_app.render():
result = dialog.render(rl.Rectangle(100, 100, 1024, 800))
if result >= 0:
print(f"Selected: {dialog.selection}" if result > 0 else "Canceled")
break
return -1