Merge branch 'upstream/openpilot/master' into sync-20251114

# Conflicts:
#	.github/workflows/ci_weekly_run.yaml
#	.github/workflows/raylib_ui_preview.yaml
#	.github/workflows/tests.yaml
#	.gitmodules
#	README.md
#	SConstruct
#	common/api.py
#	common/params_keys.h
#	docs/CARS.md
#	msgq_repo
#	opendbc_repo
#	panda
#	selfdrive/car/tests/test_car_interfaces.py
#	selfdrive/controls/controlsd.py
#	selfdrive/controls/lib/latcontrol.py
#	selfdrive/controls/lib/latcontrol_angle.py
#	selfdrive/controls/lib/latcontrol_pid.py
#	selfdrive/controls/lib/latcontrol_torque.py
#	selfdrive/controls/tests/test_latcontrol.py
#	selfdrive/monitoring/helpers.py
#	selfdrive/ui/SConscript
#	selfdrive/ui/main.cc
#	selfdrive/ui/qt/body.h
#	selfdrive/ui/qt/home.cc
#	selfdrive/ui/qt/home.h
#	selfdrive/ui/qt/network/networking.cc
#	selfdrive/ui/qt/network/networking.h
#	selfdrive/ui/qt/network/wifi_manager.cc
#	selfdrive/ui/qt/offroad/developer_panel.cc
#	selfdrive/ui/qt/offroad/developer_panel.h
#	selfdrive/ui/qt/offroad/experimental_mode.cc
#	selfdrive/ui/qt/offroad/firehose.cc
#	selfdrive/ui/qt/offroad/firehose.h
#	selfdrive/ui/qt/offroad/onboarding.cc
#	selfdrive/ui/qt/offroad/onboarding.h
#	selfdrive/ui/qt/offroad/settings.cc
#	selfdrive/ui/qt/offroad/settings.h
#	selfdrive/ui/qt/offroad/software_settings.cc
#	selfdrive/ui/qt/onroad/alerts.cc
#	selfdrive/ui/qt/onroad/annotated_camera.h
#	selfdrive/ui/qt/onroad/buttons.cc
#	selfdrive/ui/qt/onroad/buttons.h
#	selfdrive/ui/qt/onroad/driver_monitoring.cc
#	selfdrive/ui/qt/onroad/hud.cc
#	selfdrive/ui/qt/onroad/hud.h
#	selfdrive/ui/qt/onroad/model.cc
#	selfdrive/ui/qt/onroad/model.h
#	selfdrive/ui/qt/onroad/onroad_home.cc
#	selfdrive/ui/qt/onroad/onroad_home.h
#	selfdrive/ui/qt/request_repeater.h
#	selfdrive/ui/qt/sidebar.cc
#	selfdrive/ui/qt/sidebar.h
#	selfdrive/ui/qt/util.cc
#	selfdrive/ui/qt/widgets/cameraview.h
#	selfdrive/ui/qt/widgets/controls.cc
#	selfdrive/ui/qt/widgets/controls.h
#	selfdrive/ui/qt/widgets/input.cc
#	selfdrive/ui/qt/widgets/input.h
#	selfdrive/ui/qt/widgets/prime.cc
#	selfdrive/ui/qt/widgets/prime.h
#	selfdrive/ui/qt/widgets/ssh_keys.h
#	selfdrive/ui/qt/widgets/toggle.h
#	selfdrive/ui/qt/widgets/wifi.cc
#	selfdrive/ui/qt/widgets/wifi.h
#	selfdrive/ui/qt/window.cc
#	selfdrive/ui/qt/window.h
#	selfdrive/ui/tests/cycle_offroad_alerts.py
#	selfdrive/ui/tests/test_ui/run.py
#	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_nl.ts
#	selfdrive/ui/translations/main_pl.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
#	selfdrive/ui/ui.cc
#	selfdrive/ui/ui.h
#	system/manager/build.py
#	system/version.py
This commit is contained in:
Jason Wen
2025-11-16 02:00:29 -05:00
496 changed files with 22367 additions and 59319 deletions
+280 -100
View File
@@ -2,8 +2,11 @@ import atexit
import cffi
import os
import time
import signal
import sys
import pyray as rl
import threading
from contextlib import contextmanager
from collections.abc import Callable
from collections import deque
from dataclasses import dataclass
@@ -12,38 +15,50 @@ from typing import NamedTuple
from importlib.resources import as_file, files
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.multilang import multilang
from openpilot.common.realtime import Ratekeeper
DEFAULT_FPS = int(os.getenv("FPS", "60"))
_DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20}.get(HARDWARE.get_device_type(), 60)))
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
MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
MAX_TOUCH_SLOTS = 2
TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == "1"
SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1"
STRICT_MODE = os.getenv("STRICT_MODE") == "1"
SCALE = float(os.getenv("SCALE", "1.0"))
PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0"))
PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.WHITE
# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles
# The real scales for the fonts below range from 1.212 to 1.266
FONT_SCALE = 1.242
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
THIN = "Inter-Thin.ttf"
EXTRA_LIGHT = "Inter-ExtraLight.ttf"
LIGHT = "Inter-Light.ttf"
NORMAL = "Inter-Regular.ttf"
MEDIUM = "Inter-Medium.ttf"
SEMI_BOLD = "Inter-SemiBold.ttf"
BOLD = "Inter-Bold.ttf"
EXTRA_BOLD = "Inter-ExtraBold.ttf"
BLACK = "Inter-Black.ttf"
LIGHT = "Inter-Light.fnt"
NORMAL = "Inter-Regular.fnt"
MEDIUM = "Inter-Medium.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
BOLD = "Inter-Bold.fnt"
UNIFONT = "unifont.fnt"
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
if multilang.requires_unifont():
return gui_app.font(FontWeight.UNIFONT)
return font
@dataclass
@@ -57,6 +72,12 @@ class MousePos(NamedTuple):
y: float
class MousePosWithTime(NamedTuple):
x: float
y: float
t: float
class MouseEvent(NamedTuple):
pos: MousePos
slot: int
@@ -72,7 +93,7 @@ class MouseState:
self._events: deque[MouseEvent] = deque(maxlen=MOUSE_THREAD_RATE) # bound event list
self._prev_mouse_event: list[MouseEvent | None] = [None] * MAX_TOUCH_SLOTS
self._rk = Ratekeeper(MOUSE_THREAD_RATE)
self._rk = Ratekeeper(MOUSE_THREAD_RATE, print_delay_threshold=None)
self._lock = threading.Lock()
self._exit_event = threading.Event()
self._thread = None
@@ -108,8 +129,8 @@ class MouseState:
ev = MouseEvent(
MousePos(x, y),
slot,
rl.is_mouse_button_pressed(slot),
rl.is_mouse_button_released(slot),
rl.is_mouse_button_pressed(slot), # noqa: TID251
rl.is_mouse_button_released(slot), # noqa: TID251
rl.is_mouse_button_down(slot),
time.monotonic(),
)
@@ -125,94 +146,177 @@ class GuiApplication:
self._fonts: dict[FontWeight, rl.Font] = {}
self._width = width
self._height = height
self._scale = SCALE
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
else:
self._scale = SCALE
self._scaled_width = int(self._width * self._scale)
self._scaled_height = int(self._height * self._scale)
self._render_texture: rl.RenderTexture | None = None
self._textures: dict[str, rl.Texture] = {}
self._target_fps: int = DEFAULT_FPS
self._target_fps: int = _DEFAULT_FPS
self._last_fps_log_time: float = time.monotonic()
self._frame = 0
self._window_close_requested = False
self._trace_log_callback = None
self._modal_overlay = ModalOverlay()
self._modal_overlay_shown = False
self._mouse = MouseState(self._scale)
self._mouse_events: list[MouseEvent] = []
self._last_mouse_event: MouseEvent = MouseEvent(MousePos(0, 0), 0, False, False, False, 0.0)
self._should_render = True
# Debug variables
self._mouse_history: deque[MousePos] = deque(maxlen=MOUSE_THREAD_RATE)
self._mouse_history: deque[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE)
self._show_touches = SHOW_TOUCHES
self._show_fps = SHOW_FPS
self._profile_render_frames = PROFILE_RENDER
self._render_profiler = None
self._render_profile_start_time = None
@property
def frame(self):
return self._frame
def set_show_touches(self, show: bool):
self._show_touches = show
def set_show_fps(self, show: bool):
self._show_fps = show
@property
def target_fps(self):
return self._target_fps
def request_close(self):
self._window_close_requested = True
def init_window(self, title: str, fps: int = DEFAULT_FPS):
atexit.register(self.close) # Automatically call close() on exit
def init_window(self, title: str, fps: int = _DEFAULT_FPS):
with self._startup_profile_context():
def _close(sig, frame):
self.close()
sys.exit(0)
signal.signal(signal.SIGINT, _close)
atexit.register(self.close)
HARDWARE.set_display_power(True)
HARDWARE.set_screen_brightness(65)
self._set_log_callback()
rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING)
self._set_log_callback()
rl.set_trace_log_level(rl.TraceLogLevel.LOG_ALL)
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
if ENABLE_VSYNC:
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
rl.set_config_flags(flags)
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
if ENABLE_VSYNC:
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
rl.set_config_flags(flags)
rl.init_window(self._scaled_width, self._scaled_height, title)
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
self._render_texture = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_target_fps(fps)
rl.init_window(self._scaled_width, self._scaled_height, title)
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
self._render_texture = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_target_fps(fps)
self._target_fps = fps
self._set_styles()
self._load_fonts()
self._patch_text_functions()
self._target_fps = fps
self._set_styles()
self._load_fonts()
if not PC:
self._mouse.start()
if not PC:
self._mouse.start()
@contextmanager
def _startup_profile_context(self):
if "PROFILE_STARTUP" not in os.environ:
yield
return
import cProfile
import io
import pstats
profiler = cProfile.Profile()
start_time = time.monotonic()
profiler.enable()
# do the init
yield
profiler.disable()
elapsed_ms = (time.monotonic() - start_time) * 1e3
stats_stream = io.StringIO()
pstats.Stats(profiler, stream=stats_stream).sort_stats("cumtime").print_stats(25)
print("\n=== Startup profile ===")
print(stats_stream.getvalue().rstrip())
green = "\033[92m"
reset = "\033[0m"
print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}")
sys.exit(0)
def set_modal_overlay(self, overlay, callback: Callable | None = None):
if self._modal_overlay.overlay is not None:
if self._modal_overlay.callback is not None:
self._modal_overlay.callback(-1)
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):
def set_should_render(self, should_render: bool):
self._should_render = should_render
def texture(self, asset_path: str, width: int | None = None, height: int | None = None,
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:
return self._textures[cache_key]
with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath:
texture_obj = self._load_texture_from_image(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
def _load_texture_from_image(self, image_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True):
"""Load and resize a texture, storing it for later automatic unloading."""
def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply: bool = False, keep_aspect_ratio: bool = True) -> rl.Image:
"""Load and resize an image, storing it for later automatic unloading."""
image = rl.load_image(image_path)
if alpha_premultiply:
rl.image_alpha_premultiply(image)
# Resize with aspect ratio preservation if requested
if keep_aspect_ratio:
orig_width = image.width
orig_height = image.height
if width is not None and height is not None:
same_dimensions = image.width == width and image.height == height
scale_width = width / orig_width
scale_height = height / orig_height
# Resize with aspect ratio preservation if requested
if not same_dimensions:
if keep_aspect_ratio:
orig_width = image.width
orig_height = image.height
# Calculate new dimensions
scale = min(scale_width, scale_height)
new_width = int(orig_width * scale)
new_height = int(orig_height * scale)
scale_width = width / orig_width
scale_height = height / orig_height
rl.image_resize(image, new_width, new_height)
# Calculate new dimensions
scale = min(scale_width, scale_height)
new_width = int(orig_width * scale)
new_height = int(orig_height * scale)
rl.image_resize(image, new_width, new_height)
else:
rl.image_resize(image, width, height)
else:
rl.image_resize(image, width, height)
assert keep_aspect_ratio, "Cannot resize without specifying width and height"
return image
def _load_texture_from_image(self, image: rl.Image) -> rl.Texture:
"""Send image to GPU and unload original image."""
texture = rl.load_texture_from_image(image)
# Set texture filtering to smooth the result
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
# prevent artifacts from wrapping coordinates
rl.set_texture_wrap(texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP)
rl.unload_image(image)
return texture
@@ -242,8 +346,18 @@ class GuiApplication:
def mouse_events(self) -> list[MouseEvent]:
return self._mouse_events
@property
def last_mouse_event(self) -> MouseEvent:
return self._last_mouse_event
def render(self):
try:
if self._profile_render_frames > 0:
import cProfile
self._render_profiler = cProfile.Profile()
self._render_profile_start_time = time.monotonic()
self._render_profiler.enable()
while not (self._window_close_requested or rl.window_should_close()):
if PC:
# Thread is not used on PC, need to manually add mouse events
@@ -251,6 +365,16 @@ class GuiApplication:
# Store all mouse events for the current frame
self._mouse_events = self._mouse.get_events()
if len(self._mouse_events) > 0:
self._last_mouse_event = self._mouse_events[-1]
# Skip rendering when screen is off
if not self._should_render:
if PC:
rl.poll_input_events()
time.sleep(1 / self._target_fps)
yield False
continue
if self._render_texture:
rl.begin_texture_mode(self._render_texture)
@@ -260,22 +384,10 @@ class GuiApplication:
rl.clear_background(rl.BLACK)
# 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()
if self._handle_modal_overlay():
yield False
else:
yield
yield True
if self._render_texture:
rl.end_texture_mode()
@@ -285,29 +397,22 @@ class GuiApplication:
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
rl.draw_texture_pro(self._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
if SHOW_FPS:
if self._show_fps:
rl.draw_fps(10, 10)
if SHOW_TOUCHES:
for mouse_event in self._mouse_events:
if mouse_event.left_pressed:
self._mouse_history.clear()
self._mouse_history.append(mouse_event.pos)
if self._mouse_history:
mouse_pos = self._mouse_history[-1]
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 15, rl.RED)
for idx, mouse_pos in enumerate(self._mouse_history):
perc = idx / len(self._mouse_history)
color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255)
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color)
if self._show_touches:
self._draw_touch_points()
rl.end_drawing()
self._monitor_fps()
self._frame += 1
if self._profile_render_frames > 0 and self._frame >= self._profile_render_frames:
self._output_render_profile()
except KeyboardInterrupt:
pass
def font(self, font_weight: FontWeight = FontWeight.NORMAL):
def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font:
return self._fonts[font_weight]
@property
@@ -318,26 +423,39 @@ class GuiApplication:
def height(self):
return self._height
def _handle_modal_overlay(self) -> bool:
if self._modal_overlay.overlay:
if hasattr(self._modal_overlay.overlay, 'render'):
result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height))
elif callable(self._modal_overlay.overlay):
result = self._modal_overlay.overlay()
else:
raise Exception
# Send show event to Widget
if not self._modal_overlay_shown and hasattr(self._modal_overlay.overlay, 'show_event'):
self._modal_overlay.overlay.show_event()
self._modal_overlay_shown = True
if result >= 0:
# Clear the overlay and execute the callback
original_modal = self._modal_overlay
self._modal_overlay = ModalOverlay()
if original_modal.callback is not None:
original_modal.callback(result)
return True
else:
self._modal_overlay_shown = False
return False
def _load_fonts(self):
# Create a character set from our keyboard layouts
from openpilot.system.ui.widgets.keyboard import KEYBOARD_LAYOUTS
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 += "–✓×°"
codepoint_count = rl.ffi.new("int *", 1)
codepoints = rl.load_codepoints(all_chars, codepoint_count)
for font_weight_file in FontWeight:
with as_file(FONT_DIR.joinpath(font_weight_file)) as fspath:
font = rl.load_font_ex(fspath.as_posix(), 200, codepoints, codepoint_count[0])
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
with as_file(FONT_DIR) as fspath:
fnt_path = fspath / font_weight_file
font = rl.load_font(fnt_path.as_posix())
if font_weight_file != FontWeight.UNIFONT:
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._fonts[font_weight_file] = font
rl.unload_codepoints(codepoints)
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
def _set_styles(self):
@@ -347,6 +465,17 @@ class GuiApplication:
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR))
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255)))
def _patch_text_functions(self):
# Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt
if not hasattr(rl, "_orig_draw_text_ex"):
rl._orig_draw_text_ex = rl.draw_text_ex
def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint):
font = font_fallback(font)
return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint)
rl.draw_text_ex = _draw_text_ex_scaled
def _set_log_callback(self):
ffi_libc = cffi.FFI()
ffi_libc.cdef("""
@@ -402,5 +531,56 @@ class GuiApplication:
cloudlog.error(f"FPS dropped critically below {fps}. Shutting down UI.")
os._exit(1)
def _draw_touch_points(self):
current_time = time.monotonic()
for mouse_event in self._mouse_events:
if mouse_event.left_pressed:
self._mouse_history.clear()
self._mouse_history.append(MousePosWithTime(mouse_event.pos.x * self._scale, mouse_event.pos.y * self._scale, current_time))
# Remove old touch points that exceed the timeout
while self._mouse_history and (current_time - self._mouse_history[0].t) > TOUCH_HISTORY_TIMEOUT:
self._mouse_history.popleft()
if self._mouse_history:
mouse_pos = self._mouse_history[-1]
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 15, rl.RED)
for idx, mouse_pos in enumerate(self._mouse_history):
perc = idx / len(self._mouse_history)
color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255)
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color)
def _output_render_profile(self):
import io
import pstats
self._render_profiler.disable()
elapsed_ms = (time.monotonic() - self._render_profile_start_time) * 1e3
avg_frame_time = elapsed_ms / self._frame if self._frame > 0 else 0
stats_stream = io.StringIO()
pstats.Stats(self._render_profiler, stream=stats_stream).sort_stats("cumtime").print_stats(PROFILE_STATS)
print("\n=== Render loop profile ===")
print(stats_stream.getvalue().rstrip())
green = "\033[92m"
reset = "\033[0m"
print(f"\n{green}Rendered {self._frame} frames in {elapsed_ms:.1f} ms{reset}")
print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000/avg_frame_time:.1f} FPS){reset}")
sys.exit(0)
def _calculate_auto_scale(self) -> float:
# Create temporary window to query monitor info
rl.init_window(1, 1, "")
w, h = rl.get_monitor_width(0), rl.get_monitor_height(0)
rl.close_window()
if w == 0 or h == 0 or (w >= self._width and h >= self._height):
return 1.0
# Apply 0.95 factor for window decorations/taskbar margin
return max(0.3, min(w / self._width, h / self._height) * 0.95)
gui_app = GuiApplication(2160, 1080)
+16 -8
View File
@@ -4,6 +4,9 @@ import re
from PIL import Image, ImageDraw, ImageFont
import pyray as rl
from openpilot.system.ui.lib.application import FONT_DIR
_emoji_font: ImageFont.FreeTypeFont | None = None
_cache: dict[str, rl.Texture] = {}
EMOJI_REGEX = re.compile(
@@ -26,10 +29,16 @@ EMOJI_REGEX = re.compile(
\u231a
\ufe0f
\u3030
]+""",
]+""".replace("\n", ""),
flags=re.UNICODE
)
def _load_emoji_font() -> ImageFont.FreeTypeFont | None:
global _emoji_font
if _emoji_font is None:
_emoji_font = ImageFont.truetype(str(FONT_DIR.joinpath("NotoColorEmoji.ttf")), 109)
return _emoji_font
def find_emoji(text):
return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)]
@@ -37,11 +46,10 @@ def emoji_tex(emoji):
if emoji not in _cache:
img = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype("NotoColorEmoji", 109)
draw.text((0, 0), emoji, font=font, embedded_color=True)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
l = buffer.tell()
buffer.seek(0)
_cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l))
draw.text((0, 0), emoji, font=_load_emoji_font(), embedded_color=True)
with io.BytesIO() as buffer:
img.save(buffer, format="PNG")
l = buffer.tell()
buffer.seek(0)
_cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l))
return _cache[emoji]
+88
View File
@@ -0,0 +1,88 @@
from importlib.resources import files
import os
import json
import gettext
from openpilot.common.basedir import BASEDIR
from openpilot.common.swaglog import cloudlog
try:
from openpilot.common.params import Params
except ImportError:
Params = None
SYSTEM_UI_DIR = os.path.join(BASEDIR, "system", "ui")
UI_DIR = files("openpilot.selfdrive.ui")
TRANSLATIONS_DIR = UI_DIR.joinpath("translations")
LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json")
UNIFONT_LANGUAGES = [
"ar",
"th",
"zh-CHT",
"zh-CHS",
"ko",
"ja",
]
class Multilang:
def __init__(self):
self._params = Params() if Params is not None else None
self._language: str = "en"
self.languages = {}
self.codes = {}
self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations()
self._load_languages()
@property
def language(self) -> str:
return self._language
def requires_unifont(self) -> bool:
"""Certain languages require unifont to render their glyphs."""
return self._language in UNIFONT_LANGUAGES
def setup(self):
try:
with TRANSLATIONS_DIR.joinpath(f'app_{self._language}.mo').open('rb') as fh:
translation = gettext.GNUTranslations(fh)
translation.install()
self._translation = translation
cloudlog.warning(f"Loaded translations for language: {self._language}")
except FileNotFoundError:
cloudlog.error(f"No translation file found for language: {self._language}, using default.")
gettext.install('app')
self._translation = gettext.NullTranslations()
def change_language(self, language_code: str) -> None:
# Reinstall gettext with the selected language
self._params.put("LanguageSetting", language_code)
self._language = language_code
self.setup()
def tr(self, text: str) -> str:
return self._translation.gettext(text)
def trn(self, singular: str, plural: str, n: int) -> str:
return self._translation.ngettext(singular, plural, n)
def _load_languages(self):
with LANGUAGES_FILE.open(encoding='utf-8') as f:
self.languages = json.load(f)
self.codes = {v: k for k, v in self.languages.items()}
if self._params is not None:
lang = str(self._params.get("LanguageSetting")).removeprefix("main_")
if lang in self.codes:
self._language = lang
multilang = Multilang()
multilang.setup()
tr, trn = multilang.tr, multilang.trn
# no-op marker for static strings translated later
def tr_noop(s: str) -> str:
return s
+3 -1
View File
@@ -21,9 +21,11 @@ NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint'
NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings'
NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings'
NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection'
NM_ACTIVE_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Connection.Active'
NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless'
NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'
NM_DEVICE_IFACE = "org.freedesktop.NetworkManager.Device"
NM_DEVICE_IFACE = 'org.freedesktop.NetworkManager.Device'
NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
NM_DEVICE_TYPE_WIFI = 2
NM_DEVICE_TYPE_MODEM = 8
+99 -154
View File
@@ -1,189 +1,134 @@
import time
import math
import pyray as rl
from collections import deque
from enum import IntEnum
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos
from openpilot.system.ui.lib.application import gui_app, MouseEvent
from openpilot.common.filter_simple import FirstOrderFilter
# 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 = 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
FLICK_MULTIPLIER = 1.8 # Multiplier for flick gestures
VELOCITY_HISTORY_SIZE = 5 # Track velocity over multiple frames for smoother motion
MOUSE_WHEEL_SCROLL_SPEED = 50
BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps
MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click
DEBUG = False
class ScrollState(IntEnum):
IDLE = 0
DRAGGING_CONTENT = 1
DRAGGING_SCROLLBAR = 2
BOUNCING = 3
IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia
DRAGGING_CONTENT = 1 # User is actively dragging the content
class GuiScrollPanel:
def __init__(self, show_vertical_scroll_bar: bool = False):
def __init__(self):
self._scroll_state: ScrollState = ScrollState.IDLE
self._last_mouse_y: float = 0.0
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection
self._offset = rl.Vector2(0, 0)
self._view = rl.Rectangle(0, 0, 0, 0)
self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar
self._velocity_y = 0.0 # Velocity for inertia
self._is_dragging: bool = False
self._bounce_offset: float = 0.0
self._velocity_history: deque[float] = deque(maxlen=VELOCITY_HISTORY_SIZE)
self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self._last_drag_time: float = 0.0
self._content_rect: rl.Rectangle | None = None
self._bounds_rect: rl.Rectangle | None = None
def handle_scroll(self, bounds: rl.Rectangle, content: rl.Rectangle) -> rl.Vector2:
# TODO: HACK: this class is driven by mouse events, so we need to ensure we have at least one event to process
for mouse_event in gui_app.mouse_events or [MouseEvent(MousePos(0, 0), 0, False, False, False, time.monotonic())]:
def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float:
for mouse_event in gui_app.mouse_events:
if mouse_event.slot == 0:
self._handle_mouse_event(mouse_event, bounds, content)
return self._offset
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle):
# Store rectangles for reference
self._content_rect = content
self._bounds_rect = bounds
self._update_state(bounds, content)
max_scroll_y = max(content.height - bounds.height, 0)
return float(self._offset_filter_y.x)
# Start dragging on mouse press
if rl.check_collision_point_rec(mouse_event.pos, bounds) and mouse_event.left_pressed:
if self._scroll_state == ScrollState.IDLE or self._scroll_state == ScrollState.BOUNCING:
self._scroll_state = ScrollState.DRAGGING_CONTENT
if self._show_vertical_scroll_bar:
scrollbar_width = rl.gui_get_style(rl.GuiControl.LISTVIEW, rl.GuiListViewProperty.SCROLLBAR_WIDTH)
scrollbar_x = bounds.x + bounds.width - scrollbar_width
if mouse_event.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_event.pos.y
self._start_mouse_y = mouse_event.pos.y
self._last_drag_time = mouse_event.t
self._velocity_history.clear()
self._velocity_y = 0.0
self._bounce_offset = 0.0
# Handle active dragging
if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
if mouse_event.left_down:
delta_y = mouse_event.pos.y - self._last_mouse_y
# Track velocity for inertia
time_since_last_drag = mouse_event.t - self._last_drag_time
if time_since_last_drag > 0:
# TODO: HACK: /2 since we usually get two touch events per frame
drag_velocity = delta_y / time_since_last_drag / 60.0 / 2 # TODO: shouldn't be hardcoded
self._velocity_history.append(drag_velocity)
self._last_drag_time = mouse_event.t
# Detect actual dragging
total_drag = abs(mouse_event.pos.y - self._start_mouse_y)
if total_drag > DRAG_THRESHOLD:
self._is_dragging = True
if self._scroll_state == ScrollState.DRAGGING_CONTENT:
# Add resistance at boundaries
if (self._offset.y > 0 and delta_y > 0) or (self._offset.y < -max_scroll_y and delta_y < 0):
delta_y *= BOUNCE_FACTOR
self._offset.y += delta_y
elif self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
scroll_ratio = content.height / bounds.height
self._offset.y -= delta_y * scroll_ratio
self._last_mouse_y = mouse_event.pos.y
elif mouse_event.left_released:
# Calculate flick velocity
if self._velocity_history:
total_weight = 0
weighted_velocity = 0.0
for i, v in enumerate(self._velocity_history):
weight = i + 1
weighted_velocity += v * weight
total_weight += weight
if total_weight > 0:
avg_velocity = weighted_velocity / total_weight
self._velocity_y = avg_velocity * FLICK_MULTIPLIER
# Check bounds
if self._offset.y > 0 or self._offset.y < -max_scroll_y:
self._scroll_state = ScrollState.BOUNCING
else:
self._scroll_state = ScrollState.IDLE
def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle):
if DEBUG:
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED)
# Handle mouse wheel
wheel_move = rl.get_mouse_wheel_move()
if wheel_move != 0:
self._velocity_y = 0.0
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED
if self._show_vertical_scroll_bar:
self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20)
rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view)
else:
self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED
if self._offset.y > 0 or self._offset.y < -max_scroll_y:
self._scroll_state = ScrollState.BOUNCING
# Apply inertia (continue scrolling after mouse release)
max_scroll_distance = max(0, content.height - bounds.height)
if self._scroll_state == ScrollState.IDLE:
if abs(self._velocity_y) > MIN_VELOCITY:
self._offset.y += self._velocity_y
self._velocity_y *= INERTIA_FRICTION
above_bounds, below_bounds = self._check_bounds(bounds, content)
if self._offset.y > 0 or self._offset.y < -max_scroll_y:
self._scroll_state = ScrollState.BOUNCING
# Decay velocity when idle
if abs(self._velocity_filter_y.x) > MIN_VELOCITY:
# Faster decay if bouncing back from out of bounds
friction = math.exp(-BOUNCE_RETURN_RATE * 1 / gui_app.target_fps)
self._velocity_filter_y.x *= friction ** 2 if (above_bounds or below_bounds) else friction
else:
self._velocity_y = 0.0
self._velocity_filter_y.x = 0.0
# Handle bouncing effect
elif self._scroll_state == ScrollState.BOUNCING:
target_y = 0.0
if self._offset.y < -max_scroll_y:
target_y = -max_scroll_y
if above_bounds or below_bounds:
if above_bounds:
self._offset_filter_y.update(0)
else:
self._offset_filter_y.update(-max_scroll_distance)
distance = target_y - self._offset.y
bounce_step = distance * BOUNCE_RETURN_SPEED
self._offset.y += bounce_step
self._velocity_y *= INERTIA_FRICTION * 0.8
self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps
if abs(distance) < 0.5 and abs(self._velocity_y) < MIN_VELOCITY:
self._offset.y = target_y
self._velocity_y = 0.0
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
# Mouse not moving, decay velocity
if not len(gui_app.mouse_events):
self._velocity_filter_y.update(0.0)
# Settle to exact bounds
if abs(self._offset_filter_y.x) < 1e-2:
self._offset_filter_y.x = 0.0
elif abs(self._offset_filter_y.x + max_scroll_distance) < 1e-2:
self._offset_filter_y.x = -max_scroll_distance
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle):
if self._scroll_state == ScrollState.IDLE:
if rl.check_collision_point_rec(mouse_event.pos, bounds):
if mouse_event.left_pressed:
self._start_mouse_y = mouse_event.pos.y
# Interrupt scrolling with new drag
# TODO: stop scrolling with any tap, need to fix is_touch_valid
if abs(self._velocity_filter_y.x) > MIN_VELOCITY_FOR_CLICKING:
self._scroll_state = ScrollState.DRAGGING_CONTENT
# Start velocity at initial measurement for more immediate response
self._velocity_filter_y.initialized = False
if mouse_event.left_down:
if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD:
self._scroll_state = ScrollState.DRAGGING_CONTENT
# Start velocity at initial measurement for more immediate response
self._velocity_filter_y.initialized = False
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
if mouse_event.left_released:
self._scroll_state = ScrollState.IDLE
else:
delta_y = mouse_event.pos.y - self._last_mouse_y
above_bounds, below_bounds = self._check_bounds(bounds, content)
# Rubber banding effect when out of bands
if above_bounds or below_bounds:
delta_y /= 3
# Limit bounce distance
if self._scroll_state != ScrollState.DRAGGING_CONTENT:
if self._offset.y > MAX_BOUNCE_DISTANCE:
self._offset.y = MAX_BOUNCE_DISTANCE
elif self._offset.y < -(max_scroll_y + MAX_BOUNCE_DISTANCE):
self._offset.y = -(max_scroll_y + MAX_BOUNCE_DISTANCE)
self._offset_filter_y.x += delta_y
# Track velocity for inertia
dt = mouse_event.t - self._last_drag_time
if dt > 0:
drag_velocity = delta_y / dt
self._velocity_filter_y.update(drag_velocity)
# TODO: just store last mouse event!
self._last_drag_time = mouse_event.t
self._last_mouse_y = mouse_event.pos.y
def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]:
max_scroll_distance = max(0, content.height - bounds.height)
above_bounds = self._offset_filter_y.x > 0
below_bounds = self._offset_filter_y.x < -max_scroll_distance
return above_bounds, below_bounds
def is_touch_valid(self):
return not self._is_dragging
return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING
def get_normalized_scroll_position(self) -> float:
"""Returns the current scroll position as a value from 0.0 to 1.0"""
if not self._content_rect or not self._bounds_rect:
return 0.0
def set_offset(self, position: float) -> None:
self._offset_filter_y.x = position
self._velocity_filter_y.x = 0.0
self._scroll_state = ScrollState.IDLE
max_scroll_y = max(self._content_rect.height - self._bounds_rect.height, 0)
if max_scroll_y == 0:
return 0.0
normalized = -self._offset.y / max_scroll_y
return max(0.0, min(1.0, normalized))
@property
def offset(self) -> float:
return float(self._offset_filter_y.x)
+104 -183
View File
@@ -1,9 +1,33 @@
import platform
import pyray as rl
import numpy as np
from typing import Any
from dataclasses import dataclass
from typing import Any, Optional, cast
from openpilot.system.ui.lib.application import gui_app
MAX_GRADIENT_COLORS = 20 # includes stops as well
@dataclass
class Gradient:
start: tuple[float, float]
end: tuple[float, float]
colors: list[rl.Color]
stops: list[float]
def __post_init__(self):
if len(self.colors) > MAX_GRADIENT_COLORS:
self.colors = self.colors[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient colors truncated to {MAX_GRADIENT_COLORS} entries")
if len(self.stops) > MAX_GRADIENT_COLORS:
self.stops = self.stops[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient stops truncated to {MAX_GRADIENT_COLORS} entries")
if not len(self.stops):
color_count = min(len(self.colors), MAX_GRADIENT_COLORS)
self.stops = [i / max(1, color_count - 1) for i in range(color_count)]
MAX_GRADIENT_COLORS = 15
VERSION = """
#version 300 es
@@ -18,106 +42,43 @@ FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
uniform vec2 points[100];
uniform int pointCount;
uniform vec4 fillColor;
uniform vec2 resolution;
// Gradient line defined in *screen pixels*
uniform int useGradient;
uniform vec2 gradientStart;
uniform vec2 gradientEnd;
uniform vec4 gradientColors[15];
uniform float gradientStops[15];
uniform vec2 gradientStart; // e.g. vec2(0, 0)
uniform vec2 gradientEnd; // e.g. vec2(0, screenHeight)
uniform vec4 gradientColors[20];
uniform float gradientStops[20];
uniform int gradientColorCount;
vec4 getGradientColor(vec2 pos) {
vec2 gradientDir = gradientEnd - gradientStart;
float gradientLength = length(gradientDir);
if (gradientLength < 0.001) return gradientColors[0];
vec4 getGradientColor(vec2 p) {
// Compute t from screen-space position
vec2 d = gradientStart - gradientEnd;
float len2 = max(dot(d, d), 1e-6);
float t = clamp(dot(p - gradientEnd, d) / len2, 0.0, 1.0);
vec2 normalizedDir = gradientDir / gradientLength;
float t = clamp(dot(pos - gradientStart, normalizedDir) / gradientLength, 0.0, 1.0);
// Clamp to range
float t0 = gradientStops[0];
float tn = gradientStops[gradientColorCount-1];
if (t <= t0) return gradientColors[0];
if (t >= tn) return gradientColors[gradientColorCount-1];
if (gradientColorCount <= 1) return gradientColors[0];
// handle t before first / after last stop
if (t <= gradientStops[0]) return gradientColors[0];
if (t >= gradientStops[gradientColorCount-1]) return gradientColors[gradientColorCount-1];
for (int i = 0; i < gradientColorCount - 1; i++) {
if (t >= gradientStops[i] && t <= gradientStops[i+1]) {
float segmentT = (t - gradientStops[i]) / (gradientStops[i+1] - gradientStops[i]);
return mix(gradientColors[i], gradientColors[i+1], segmentT);
float a = gradientStops[i];
float b = gradientStops[i+1];
if (t >= a && t <= b) {
float k = (t - a) / max(b - a, 1e-6);
return mix(gradientColors[i], gradientColors[i+1], k);
}
}
return gradientColors[gradientColorCount-1];
}
bool isPointInsidePolygon(vec2 p) {
if (pointCount < 3) return false;
int crossings = 0;
for (int i = 0, j = pointCount - 1; i < pointCount; j = i++) {
vec2 pi = points[i];
vec2 pj = points[j];
if (distance(pi, pj) < 0.001) continue;
if (((pi.y > p.y) != (pj.y > p.y)) &&
(p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y + 0.001) + pi.x)) {
crossings++;
}
}
return (crossings & 1) == 1;
}
float distanceToEdge(vec2 p) {
float minDist = 1000.0;
for (int i = 0, j = pointCount - 1; i < pointCount; j = i++) {
vec2 edge0 = points[j];
vec2 edge1 = points[i];
if (distance(edge0, edge1) < 0.0001) continue;
vec2 v1 = p - edge0;
vec2 v2 = edge1 - edge0;
float l2 = dot(v2, v2);
if (l2 < 0.0001) {
float dist = length(v1);
minDist = min(minDist, dist);
continue;
}
float t = clamp(dot(v1, v2) / l2, 0.0, 1.0);
vec2 projection = edge0 + t * v2;
float dist = length(p - projection);
minDist = min(minDist, dist);
}
return minDist;
}
void main() {
vec2 pixel = fragTexCoord * resolution;
// Compute pixel size for anti-aliasing
vec2 pixelGrad = vec2(dFdx(pixel.x), dFdy(pixel.y));
float pixelSize = length(pixelGrad);
float aaWidth = max(0.5, pixelSize * 1.5);
bool inside = isPointInsidePolygon(pixel);
if (inside) {
finalColor = useGradient == 1 ? getGradientColor(pixel) : fillColor;
return;
}
float sd = -distanceToEdge(pixel);
float alpha = smoothstep(-aaWidth, aaWidth, sd);
if (alpha > 0.0){
vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor;
finalColor = vec4(color.rgb, color.a * alpha);
} else {
discard;
}
// TODO: do proper antialiasing
finalColor = useGradient == 1 ? getGradientColor(gl_FragCoord.xy) : fillColor;
}
"""
@@ -155,14 +116,10 @@ class ShaderState:
self.initialized = False
self.shader = None
self.white_texture = None
# Shader uniform locations
self.locations = {
'pointCount': None,
'fillColor': None,
'resolution': None,
'points': None,
'useGradient': None,
'gradientStart': None,
'gradientEnd': None,
@@ -173,12 +130,8 @@ class ShaderState:
}
# Pre-allocated FFI objects
self.point_count_ptr = rl.ffi.new("int[]", [0])
self.resolution_ptr = rl.ffi.new("float[]", [0.0, 0.0])
self.fill_color_ptr = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self.use_gradient_ptr = rl.ffi.new("int[]", [0])
self.gradient_start_ptr = rl.ffi.new("float[]", [0.0, 0.0])
self.gradient_end_ptr = rl.ffi.new("float[]", [0.0, 0.0])
self.color_count_ptr = rl.ffi.new("int[]", [0])
self.gradient_colors_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS * 4)
self.gradient_stops_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS)
@@ -189,30 +142,19 @@ class ShaderState:
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
# Create and cache white texture
white_img = rl.gen_image_color(2, 2, rl.WHITE)
self.white_texture = rl.load_texture_from_image(white_img)
rl.set_texture_filter(self.white_texture, rl.TEXTURE_FILTER_BILINEAR)
rl.unload_image(white_img)
# Cache all uniform locations
for uniform in self.locations.keys():
self.locations[uniform] = rl.get_shader_location(self.shader, uniform)
# Setup default MVP matrix
mvp_ptr = rl.ffi.new("float[16]", [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0])
rl.set_shader_value_matrix(self.shader, self.locations['mvp'], rl.Matrix(*mvp_ptr))
# Orthographic MVP (origin top-left)
proj = rl.matrix_ortho(0, gui_app.width, gui_app.height, 0, -1, 1)
rl.set_shader_value_matrix(self.shader, self.locations['mvp'], proj)
self.initialized = True
def cleanup(self):
if not self.initialized:
return
if self.white_texture:
rl.unload_texture(self.white_texture)
self.white_texture = None
if self.shader:
rl.unload_shader(self.shader)
self.shader = None
@@ -220,103 +162,82 @@ class ShaderState:
self.initialized = False
def _configure_shader_color(state, color, gradient, clipped_rect, original_rect):
use_gradient = 1 if gradient else 0
def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], # noqa: UP045
gradient: Gradient | None, origin_rect: rl.Rectangle):
assert (color is not None) != (gradient is not None), "Either color or gradient must be provided"
use_gradient = 1 if (gradient is not None and len(gradient.colors) >= 1) else 0
state.use_gradient_ptr[0] = use_gradient
rl.set_shader_value(state.shader, state.locations['useGradient'], state.use_gradient_ptr, UNIFORM_INT)
if use_gradient:
start = np.array(gradient['start']) * np.array([original_rect.width, original_rect.height]) + np.array([original_rect.x, original_rect.y])
end = np.array(gradient['end']) * np.array([original_rect.width, original_rect.height]) + np.array([original_rect.x, original_rect.y])
start = start - np.array([clipped_rect.x, clipped_rect.y])
end = end - np.array([clipped_rect.x, clipped_rect.y])
state.gradient_start_ptr[0:2] = start.astype(np.float32)
state.gradient_end_ptr[0:2] = end.astype(np.float32)
rl.set_shader_value(state.shader, state.locations['gradientStart'], state.gradient_start_ptr, UNIFORM_VEC2)
rl.set_shader_value(state.shader, state.locations['gradientEnd'], state.gradient_end_ptr, UNIFORM_VEC2)
gradient = cast(Gradient, gradient)
state.color_count_ptr[0] = len(gradient.colors)
for i in range(len(gradient.colors)):
c = gradient.colors[i]
base = i * 4
state.gradient_colors_ptr[base:base + 4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0]
rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, len(gradient.colors))
colors = gradient['colors']
color_count = min(len(colors), MAX_GRADIENT_COLORS)
state.color_count_ptr[0] = color_count
for i, c in enumerate(colors[:color_count]):
base_idx = i * 4
state.gradient_colors_ptr[base_idx:base_idx+4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0]
rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, color_count)
stops = gradient.get('stops', [i / max(1, color_count - 1) for i in range(color_count)])
stops = np.clip(stops[:color_count], 0.0, 1.0)
state.gradient_stops_ptr[0:color_count] = stops
rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, color_count)
for i in range(len(gradient.stops)):
s = float(gradient.stops[i])
state.gradient_stops_ptr[i] = 0.0 if s < 0.0 else 1.0 if s > 1.0 else s
rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, len(gradient.stops))
rl.set_shader_value(state.shader, state.locations['gradientColorCount'], state.color_count_ptr, UNIFORM_INT)
# Map normalized start/end to screen pixels
start_vec = rl.Vector2(origin_rect.x + gradient.start[0] * origin_rect.width, origin_rect.y + gradient.start[1] * origin_rect.height)
end_vec = rl.Vector2(origin_rect.x + gradient.end[0] * origin_rect.width, origin_rect.y + gradient.end[1] * origin_rect.height)
rl.set_shader_value(state.shader, state.locations['gradientStart'], start_vec, UNIFORM_VEC2)
rl.set_shader_value(state.shader, state.locations['gradientEnd'], end_vec, UNIFORM_VEC2)
else:
color = color or rl.WHITE
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
def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
"""Only supports simple polygons with two chains (ribbon)."""
Args:
rect: Rectangle defining the drawing area
points: numpy array of (x,y) points defining the polygon
color: Solid fill color (rl.Color)
gradient: Dict with gradient parameters:
{
'start': (x1, y1), # Start point (normalized 0-1)
'end': (x2, y2), # End point (normalized 0-1)
'colors': [rl.Color], # List of colors at stops
'stops': [float] # List of positions (0-1)
}
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
assert len(pts) % 2 == 0, "Interleaving expects even number of points"
tri_strip = []
for i in range(len(pts) // 2):
tri_strip.append(pts[i])
tri_strip.append(pts[-i - 1])
return cast(list, np.array(tri_strip).tolist())
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
color: Optional[rl.Color] = None, gradient: Gradient | None = None): # noqa: UP045
"""
Draw a ribbon polygon (two chains) with a triangle strip and gradient.
- Input must be [L0..Lk-1, Rk-1..R0], even count, no crossings/holes.
"""
if len(points) < 3:
return
# Initialize shader on-demand
state = ShaderState.get_instance()
if not state.initialized:
state.initialize()
state.initialize()
# Find bounding box
min_xy = np.min(points, axis=0)
max_xy = np.max(points, axis=0)
clip_x = max(origin_rect.x, min_xy[0])
clip_y = max(origin_rect.y, min_xy[1])
clip_right = min(origin_rect.x + origin_rect.width, max_xy[0])
clip_bottom = min(origin_rect.y + origin_rect.height, max_xy[1])
# Ensure (N,2) float32 contiguous array
pts = np.ascontiguousarray(points, dtype=np.float32)
assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)"
# Check if polygon is completely off-screen
if clip_x >= clip_right or clip_y >= clip_bottom:
return
# Configure gradient shader
_configure_shader_color(state, color, gradient, origin_rect)
clipped_rect = rl.Rectangle(clip_x, clip_y, clip_right - clip_x, clip_bottom - clip_y)
# Triangulate via interleaving
tri_strip = triangulate(pts)
# Transform points relative to the CLIPPED area
transformed_points = points - np.array([clip_x, clip_y])
# Set shader values
state.point_count_ptr[0] = len(transformed_points)
rl.set_shader_value(state.shader, state.locations['pointCount'], state.point_count_ptr, UNIFORM_INT)
state.resolution_ptr[0:2] = [clipped_rect.width, clipped_rect.height]
rl.set_shader_value(state.shader, state.locations['resolution'], state.resolution_ptr, UNIFORM_VEC2)
flat_points = np.ascontiguousarray(transformed_points.flatten().astype(np.float32))
points_ptr = rl.ffi.cast("float *", flat_points.ctypes.data)
rl.set_shader_value_v(state.shader, state.locations['points'], points_ptr, UNIFORM_VEC2, len(transformed_points))
_configure_shader_color(state, color, gradient, clipped_rect, origin_rect)
# Render
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_texture_pro(
state.white_texture,
rl.Rectangle(0, 0, 2, 2),
clipped_rect,
rl.Vector2(0, 0),
0.0,
rl.WHITE,
)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
+22 -1
View File
@@ -1,14 +1,35 @@
import pyray as rl
from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback
from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
result = rl.measure_text_ex(font, text, font_size, spacing) # noqa: TID251
# Measure normal characters without emojis, then add standard width for each found emoji
emoji = find_emoji(text)
if emoji:
non_emoji_text = ""
last_index = 0
for start, end, _ in emoji:
non_emoji_text += text[last_index:start]
last_index = end
non_emoji_text += text[last_index:]
else:
non_emoji_text = text
result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251
if emoji:
result.x += len(emoji) * font_size * FONT_SCALE
# If just emoji assume a single line height
if result.y == 0:
result.y = font_size * FONT_SCALE
_cache[key] = result
return result
+406 -76
View File
@@ -2,6 +2,7 @@ import atexit
import threading
import time
import uuid
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
@@ -22,9 +23,14 @@ from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_80
NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS,
NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH,
NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE,
NM_DEVICE_TYPE_WIFI, NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT,
NM_DEVICE_STATE_REASON_NEW_ACTIVATION,
NMDeviceState)
NM_DEVICE_TYPE_WIFI, NM_DEVICE_TYPE_MODEM, NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT,
NM_DEVICE_STATE_REASON_NEW_ACTIVATION, NM_ACTIVE_CONNECTION_IFACE,
NM_IP4_CONFIG_IFACE, NMDeviceState)
try:
from openpilot.common.params import Params
except Exception:
Params = None
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
@@ -40,6 +46,12 @@ class SecurityType(IntEnum):
UNSUPPORTED = 4
class MeteredType(IntEnum):
UNKNOWN = 0
YES = 1
NO = 2
def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType:
wpa_props = wpa_flags | rsn_flags
@@ -114,7 +126,7 @@ class AccessPoint:
class WifiManager:
def __init__(self):
self._networks = [] # a network can be comprised of multiple APs
self._networks: list[Network] = [] # a network can be comprised of multiple APs
self._active = True # used to not run when not in settings
self._exit = False
@@ -132,39 +144,79 @@ class WifiManager:
# State
self._connecting_to_ssid: str = ""
self._ipv4_address: str = ""
self._current_network_metered: MeteredType = MeteredType.UNKNOWN
self._tethering_password: str = ""
self._ipv4_forward = False
self._last_network_update: float = 0.0
self._callback_queue: list[Callable] = []
self._tethering_ssid = "weedle"
if Params is not None:
dongle_id = Params().get("DongleId")
if dongle_id:
self._tethering_ssid += "-" + dongle_id[:4]
# Callbacks
self._need_auth: Callable[[str], None] | None = None
self._activated: Callable[[], None] | None = None
self._forgotten: Callable[[], None] | None = None
self._networks_updated: Callable[[list[Network]], None] | None = None
self._disconnected: Callable[[], None] | None = None
self._need_auth: list[Callable[[str], None]] = []
self._activated: list[Callable[[], None]] = []
self._forgotten: list[Callable[[], None]] = []
self._networks_updated: list[Callable[[list[Network]], None]] = []
self._disconnected: list[Callable[[], None]] = []
self._lock = threading.Lock()
self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True)
self._scan_thread.start()
self._state_thread = threading.Thread(target=self._monitor_state, daemon=True)
self._state_thread.start()
self._initialize()
atexit.register(self.stop)
def set_callbacks(self, need_auth: Callable[[str], None],
activated: Callable[[], None] | None,
forgotten: Callable[[], None],
networks_updated: Callable[[list[Network]], None],
disconnected: Callable[[], None]):
self._need_auth = need_auth
self._activated = activated
self._forgotten = forgotten
self._networks_updated = networks_updated
self._disconnected = disconnected
def _initialize(self):
def worker():
self._wait_for_wifi_device()
def _enqueue_callback(self, cb: Callable, *args):
self._callback_queue.append(lambda: cb(*args))
self._scan_thread.start()
self._state_thread.start()
if Params is not None and self._tethering_ssid not in self._get_connections():
self._add_tethering_connection()
self._tethering_password = self._get_tethering_password()
cloudlog.debug("WifiManager initialized")
threading.Thread(target=worker, daemon=True).start()
def add_callbacks(self, need_auth: Callable[[str], None] | None = None,
activated: Callable[[], None] | None = None,
forgotten: Callable[[], None] | None = None,
networks_updated: Callable[[list[Network]], None] | None = None,
disconnected: Callable[[], None] | None = None):
if need_auth is not None:
self._need_auth.append(need_auth)
if activated is not None:
self._activated.append(activated)
if forgotten is not None:
self._forgotten.append(forgotten)
if networks_updated is not None:
self._networks_updated.append(networks_updated)
if disconnected is not None:
self._disconnected.append(disconnected)
@property
def ipv4_address(self) -> str:
return self._ipv4_address
@property
def current_network_metered(self) -> MeteredType:
return self._current_network_metered
@property
def tethering_password(self) -> str:
return self._tethering_password
def _enqueue_callbacks(self, cbs: list[Callable], *args):
for cb in cbs:
self._callback_queue.append(lambda _cb=cb: _cb(*args))
def process_callbacks(self):
# Call from UI thread to run any pending callbacks
@@ -180,15 +232,11 @@ class WifiManager:
self._last_network_update = 0.0
def _monitor_state(self):
device_path = self._wait_for_wifi_device()
if device_path is None:
return
rule = MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
member="StateChanged",
path=device_path,
path=self._wifi_device,
)
# Filter for StateChanged signal
@@ -211,24 +259,20 @@ class WifiManager:
# BAD PASSWORD
if new_state == NMDeviceState.NEED_AUTH and change_reason == NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT and len(self._connecting_to_ssid):
self.forget_connection(self._connecting_to_ssid, block=True)
if self._need_auth is not None:
self._enqueue_callback(self._need_auth, self._connecting_to_ssid)
self._enqueue_callbacks(self._need_auth, self._connecting_to_ssid)
self._connecting_to_ssid = ""
elif new_state == NMDeviceState.ACTIVATED:
if self._activated is not None:
if len(self._activated):
self._update_networks()
self._enqueue_callback(self._activated)
self._enqueue_callbacks(self._activated)
self._connecting_to_ssid = ""
elif new_state == NMDeviceState.DISCONNECTED and change_reason != NM_DEVICE_STATE_REASON_NEW_ACTIVATION:
self._connecting_to_ssid = ""
if self._disconnected is not None:
self._enqueue_callback(self._disconnected)
self._enqueue_callbacks(self._forgotten)
def _network_scanner(self):
self._wait_for_wifi_device()
while not self._exit:
if self._active:
if time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS:
@@ -239,30 +283,26 @@ class WifiManager:
self._last_network_update = time.monotonic()
time.sleep(1 / 2.)
def _wait_for_wifi_device(self) -> str | None:
with self._lock:
device_path: str | None = None
while not self._exit:
device_path = self._get_wifi_device()
if device_path is not None:
break
time.sleep(1)
return device_path
def _get_wifi_device(self) -> str | None:
if self._wifi_device is not None:
return self._wifi_device
device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0]
for device_path in device_paths:
dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE)
dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1]
if dev_type == NM_DEVICE_TYPE_WIFI:
def _wait_for_wifi_device(self):
while not self._exit:
device_path = self._get_adapter(NM_DEVICE_TYPE_WIFI)
if device_path is not None:
self._wifi_device = device_path
break
time.sleep(1)
return self._wifi_device
def _get_adapter(self, adapter_type: int) -> str | None:
# Return the first NetworkManager device path matching adapter_type
try:
device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0]
for device_path in device_paths:
dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE)
dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1]
if dev_type == adapter_type:
return str(device_path)
except Exception as e:
cloudlog.exception(f"Error getting adapter type {adapter_type}: {e}")
return None
def _get_connections(self) -> dict[str, str]:
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
@@ -270,29 +310,72 @@ class WifiManager:
conns: dict[str, str] = {}
for conn_path in known_connections:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, "GetSettings"))
settings = self._get_connection_settings(conn_path)
# ignore connections removed during iteration (need auth, etc.)
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get connection properties for {conn_path}")
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
continue
settings = reply.body[0]
if "802-11-wireless" in settings:
ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace")
if ssid != "":
conns[ssid] = conn_path
return conns
def connect_to_network(self, ssid: str, password: str):
def _get_active_connections(self):
return self._router_main.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1]
def _get_connection_settings(self, conn_path: str) -> dict:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'GetSettings'))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to get connection settings: {reply}')
return {}
return dict(reply.body[0])
def _add_tethering_connection(self):
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', 'Hotspot'),
'autoconnect-retries': ('i', 0),
'interface-name': ('s', 'wlan0'),
'autoconnect': ('b', False),
},
'802-11-wireless': {
'band': ('s', 'bg'),
'mode': ('s', 'ap'),
'ssid': ('ay', self._tethering_ssid.encode("utf-8")),
},
'802-11-wireless-security': {
'group': ('as', ['ccmp']),
'key-mgmt': ('s', 'wpa-psk'),
'pairwise': ('as', ['ccmp']),
'proto': ('as', ['rsn']),
'psk': ('s', DEFAULT_TETHERING_PASSWORD),
},
'ipv4': {
'method': ('s', 'shared'),
'address-data': ('aa{sv}', [[
('address', ('s', TETHERING_IP_ADDRESS)),
('prefix', ('u', 24)),
]]),
'gateway': ('s', TETHERING_IP_ADDRESS),
'never-default': ('b', True),
},
'ipv6': {'method': ('s', 'ignore')},
}
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,)))
def connect_to_network(self, ssid: str, password: str, hidden: bool = False):
def worker():
# Clear all connections that may already exist to the network we are connecting to
self._connecting_to_ssid = ssid
self.forget_connection(ssid, block=True)
is_hidden = False
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
@@ -302,7 +385,7 @@ class WifiManager:
},
'802-11-wireless': {
'ssid': ('ay', ssid.encode("utf-8")),
'hidden': ('b', is_hidden),
'hidden': ('b', hidden),
'mode': ('s', 'infrastructure'),
},
'ipv4': {
@@ -332,9 +415,9 @@ class WifiManager:
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete'))
if self._forgotten is not None:
if len(self._forgotten):
self._update_networks()
self._enqueue_callback(self._forgotten)
self._enqueue_callbacks(self._forgotten)
if block:
worker()
@@ -358,6 +441,144 @@ class WifiManager:
else:
threading.Thread(target=worker, daemon=True).start()
def _deactivate_connection(self, ssid: str):
for conn_path in self._get_active_connections():
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1]
if specific_obj_path != "/":
ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE)
ap_ssid = bytes(self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')).body[0][1]).decode("utf-8", "replace")
if ap_ssid == ssid:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (conn_path,)))
return
def is_tethering_active(self) -> bool:
for network in self._networks:
if network.is_connected:
return bool(network.ssid == self._tethering_ssid)
return False
def set_tethering_password(self, password: str):
def worker():
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
return
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get tethering settings for {conn_path}')
return
settings['802-11-wireless-security']['psk'] = ('s', password)
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to update tethering settings: {reply}')
return
self._tethering_password = password
if self.is_tethering_active():
self.activate_connection(self._tethering_ssid, block=True)
threading.Thread(target=worker, daemon=True).start()
def _get_tethering_password(self) -> str:
conn_path = self._get_connections().get(self._tethering_ssid, None)
if conn_path is None:
cloudlog.warning('No tethering connection found')
return ''
reply = self._router_main.send_and_get_reply(new_method_call(
DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE),
'GetSecrets', 's', ('802-11-wireless-security',)
))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to get tethering password: {reply}')
return ''
secrets = reply.body[0]
if '802-11-wireless-security' not in secrets:
return ''
return str(secrets['802-11-wireless-security'].get('psk', ('s', ''))[1])
def set_ipv4_forward(self, enabled: bool):
self._ipv4_forward = enabled
def set_tethering_active(self, active: bool):
def worker():
if active:
self.activate_connection(self._tethering_ssid, block=True)
if not self._ipv4_forward:
time.sleep(5)
cloudlog.warning("net.ipv4.ip_forward = 0")
subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False)
else:
self._deactivate_connection(self._tethering_ssid)
threading.Thread(target=worker, daemon=True).start()
def _update_current_network_metered(self) -> None:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
self._current_network_metered = MeteredType.UNKNOWN
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1]
if conn_type == '802-11-wireless':
conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
if conn_path == "/":
continue
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
continue
metered_prop = settings['connection'].get('metered', ('i', 0))[1]
if metered_prop == MeteredType.YES:
self._current_network_metered = MeteredType.YES
elif metered_prop == MeteredType.NO:
self._current_network_metered = MeteredType.NO
return
def set_current_network_metered(self, metered: MeteredType):
def worker():
for active_conn in self._get_active_connections():
conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1]
if conn_type == '802-11-wireless' and not self.is_tethering_active():
conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1]
if conn_path == "/":
continue
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
return
settings['connection']['metered'] = ('i', int(metered))
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f'Failed to update tethering settings: {reply}')
return
threading.Thread(target=worker, daemon=True).start()
def _request_scan(self):
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
@@ -409,17 +630,126 @@ class WifiManager:
networks.sort(key=lambda n: (-n.is_connected, -n.strength, n.ssid.lower()))
self._networks = networks
if self._networks_updated is not None:
self._enqueue_callback(self._networks_updated, self._networks)
self._update_ipv4_address()
self._update_current_network_metered()
self._enqueue_callbacks(self._networks_updated, self._networks)
def _update_ipv4_address(self):
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
self._ipv4_address = ""
for conn_path in self._get_active_connections():
conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE)
conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1]
if conn_type == '802-11-wireless':
ip4config_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Ip4Config')).body[0][1]
if ip4config_path != "/":
ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE)
address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1]
for entry in address_data:
if 'address' in entry:
self._ipv4_address = entry['address'][1]
return
def __del__(self):
self.stop()
def update_gsm_settings(self, roaming: bool, apn: str, metered: bool):
"""Update GSM settings for cellular connection"""
def worker():
try:
lte_connection_path = self._get_lte_connection_path()
if not lte_connection_path:
cloudlog.warning("No LTE connection found")
return
settings = self._get_connection_settings(lte_connection_path)
if len(settings) == 0:
cloudlog.warning(f"Failed to get connection settings for {lte_connection_path}")
return
# Ensure dicts exist
if 'gsm' not in settings:
settings['gsm'] = {}
if 'connection' not in settings:
settings['connection'] = {}
changes = False
auto_config = apn == ""
if settings['gsm'].get('auto-config', ('b', False))[1] != auto_config:
cloudlog.warning(f'Changing gsm.auto-config to {auto_config}')
settings['gsm']['auto-config'] = ('b', auto_config)
changes = True
if settings['gsm'].get('apn', ('s', ''))[1] != apn:
cloudlog.warning(f'Changing gsm.apn to {apn}')
settings['gsm']['apn'] = ('s', apn)
changes = True
if settings['gsm'].get('home-only', ('b', False))[1] == roaming:
cloudlog.warning(f'Changing gsm.home-only to {not roaming}')
settings['gsm']['home-only'] = ('b', not roaming)
changes = True
# Unknown means NetworkManager decides
metered_int = int(MeteredType.UNKNOWN if metered else MeteredType.NO)
if settings['connection'].get('metered', ('i', 0))[1] != metered_int:
cloudlog.warning(f'Changing connection.metered to {metered_int}')
settings['connection']['metered'] = ('i', metered_int)
changes = True
if changes:
# Update the connection settings (temporary update)
conn_addr = DBusAddress(lte_connection_path, bus_name=NM, interface=NM_CONNECTION_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'UpdateUnsaved', 'a{sa{sv}}', (settings,)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to update GSM settings: {reply}")
return
self._activate_modem_connection(lte_connection_path)
except Exception as e:
cloudlog.exception(f"Error updating GSM settings: {e}")
threading.Thread(target=worker, daemon=True).start()
def _get_lte_connection_path(self) -> str | None:
try:
settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE)
known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0]
for conn_path in known_connections:
settings = self._get_connection_settings(conn_path)
if settings and settings.get('connection', {}).get('id', ('s', ''))[1] == 'lte':
return str(conn_path)
except Exception as e:
cloudlog.exception(f"Error finding LTE connection: {e}")
return None
def _activate_modem_connection(self, connection_path: str):
try:
modem_device = self._get_adapter(NM_DEVICE_TYPE_MODEM)
if modem_device and connection_path:
self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo', (connection_path, modem_device, "/")))
except Exception as e:
cloudlog.exception(f"Error activating modem connection: {e}")
def stop(self):
if not self._exit:
self._exit = True
self._scan_thread.join()
self._state_thread.join()
if self._scan_thread.is_alive():
self._scan_thread.join()
if self._state_thread.is_alive():
self._state_thread.join()
self._router_main.close()
self._router_main.conn.close()
+14 -11
View File
@@ -1,5 +1,6 @@
import pyray as rl
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
@@ -36,7 +37,15 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
return parts
_cache: dict[int, list[str]] = {}
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
font = font_fallback(font)
key = hash((font.texture.id, text, font_size, max_width))
if key in _cache:
return _cache[key]
if not text or max_width <= 0:
return []
@@ -58,8 +67,6 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
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)
@@ -70,28 +77,23 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
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
# Measure the actual joined string to get accurate width (accounts for kerning, etc.)
test_line = " ".join(current_line + [word]) if current_line else word
test_width = int(measure_text_cached(font, test_line, font_size).x)
# Check if word fits on current line
if needed_width <= max_width:
if test_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:
@@ -100,4 +102,5 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
# Add all lines from this paragraph
all_lines.extend(lines)
_cache[key] = all_lines
return all_lines
+7 -6
View File
@@ -8,7 +8,7 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
@@ -70,10 +70,10 @@ class Reset(Widget):
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100)
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100)
text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
gui_text_box(text_rect, self._get_body_text(), 90)
button_height = 160
@@ -126,9 +126,10 @@ def main():
if mode == ResetMode.FORMAT:
reset.start_reset()
for _ in gui_app.render():
if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
break
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
break
if __name__ == "__main__":
+62 -47
View File
@@ -4,6 +4,7 @@ import re
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
@@ -11,23 +12,23 @@ import shutil
import pyray as rl
from cereal import log
from openpilot.common.run import run_cmd
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import Label, TextAlignment
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
NetworkType = log.DeviceState.NetworkType
MARGIN = 50
TITLE_FONT_SIZE = 116
TITLE_FONT_SIZE = 90
TITLE_FONT_WEIGHT = FontWeight.MEDIUM
NEXT_BUTTON_WIDTH = 310
BODY_FONT_SIZE = 96
BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
@@ -48,6 +49,7 @@ cd /data/openpilot
exec ./launch_openpilot.sh
"""
class SetupState(IntEnum):
LOW_VOLTAGE = 0
GETTING_STARTED = 1
@@ -78,16 +80,17 @@ class Setup(Widget):
self.warning = gui_app.texture("icons/warning.png", 150, 150)
self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, TextAlignment.LEFT, text_color=rl.Color(255, 89, 79, 255))
self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=rl.Color(255, 89, 79, 255), text_padding=20)
self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
text_alignment=TextAlignment.LEFT)
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT)
self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT)
BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
@@ -95,36 +98,38 @@ class Setup(Widget):
button_style=ButtonStyle.PRIMARY)
self._software_selection_continue_button.set_enabled(False)
self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT)
self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_padding=20)
self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT)
self._download_failed_url_label = Label("", 64, FontWeight.NORMAL, TextAlignment.LEFT)
self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=TextAlignment.LEFT)
self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._network_setup_continue_button.set_enabled(False)
self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, TextAlignment.LEFT)
self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._custom_software_warning_continue_button.set_enabled(False)
self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
self._custom_software_warning_title_label = Label("WARNING: Custom Software", 100, FontWeight.BOLD, TextAlignment.LEFT, text_color=rl.Color(255,89,79,255),
self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=rl.Color(255, 89, 79, 255),
text_padding=60)
self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
+ "⚠️ It has not been tested by comma.\n\n"
+ "⚠️ It may not comply with relevant safety standards.\n\n"
+ "⚠️ It may cause damage to your device and/or vehicle.\n\n"
+ "If you'd like to proceed, use https://flash.comma.ai "
+ "to restore your device to a factory state later.",
85, text_alignment=TextAlignment.LEFT, text_padding=60)
+ "⚠️ It has not been tested by comma.\n\n"
+ "⚠️ It may not comply with relevant safety standards.\n\n"
+ "⚠️ It may cause damage to your device and/or vehicle.\n\n"
+ "If you'd like to proceed, use https://flash.comma.ai "
+ "to restore your device to a factory state later.",
68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM)
self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
try:
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
@@ -191,8 +196,8 @@ class Setup(Widget):
def render_low_voltage(self, rect: rl.Rectangle):
rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE))
self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * 3))
self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
button_width = (rect.width - MARGIN * 3) / 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
@@ -200,8 +205,9 @@ class Setup(Widget):
self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
def render_getting_started(self, rect: rl.Rectangle):
self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE))
self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE, rect.width - 500, BODY_FONT_SIZE * 3))
self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
BODY_FONT_SIZE * FONT_SCALE * 3))
btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
self._getting_started_button.render(btn_rect)
@@ -233,10 +239,10 @@ class Setup(Widget):
self.network_check_thread.join()
def render_network_setup(self, rect: rl.Rectangle):
self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE))
self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN + 25, rect.width - MARGIN * 2,
rect.height - TITLE_FONT_SIZE - 25 - BUTTON_HEIGHT - MARGIN * 3)
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
self.wifi_ui.render(wifi_content_rect)
@@ -254,21 +260,22 @@ class Setup(Widget):
self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_software_selection(self, rect: rl.Rectangle):
self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE))
self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
radio_height = 230
radio_spacing = 30
self._software_selection_continue_button.set_enabled(False)
openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
self._software_selection_openpilot_button.render(openpilot_rect)
if self._software_selection_openpilot_button.selected:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_custom_software_button.selected = False
custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2, radio_height)
custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
radio_height)
self._software_selection_custom_software_button.render(custom_rect)
if self._software_selection_custom_software_button.selected:
@@ -282,12 +289,13 @@ class Setup(Widget):
self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_downloading(self, rect: rl.Rectangle):
self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE / 2, rect.width, TITLE_FONT_SIZE))
self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
TITLE_FONT_SIZE * FONT_SCALE))
def render_download_failed(self, rect: rl.Rectangle):
self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE))
self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
self._download_failed_url_label.set_text(self.failed_url)
self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE + 67, rect.width - 117 - 100, 64))
self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
self._download_failed_body_label.set_text(self.failed_reason)
self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
@@ -299,20 +307,20 @@ class Setup(Widget):
def render_custom_software_warning(self, rect: rl.Rectangle):
warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
offset = self._custom_software_warning_body_scroll_panel.handle_scroll(rect, warn_rect)
offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
button_width = (rect.width - MARGIN * 3) / 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE))
y_offset = rect.y + offset.y
self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE))
self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200 , rect.width - 50, BODY_FONT_SIZE * 3))
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
y_offset = rect.y + offset
self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
rl.end_scissor_mode()
self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
if offset.y < (rect.height - warn_rect.height):
if offset < (rect.height - warn_rect.height):
self._custom_software_warning_continue_button.set_enabled(True)
self._custom_software_warning_continue_button.set_text("Continue")
@@ -329,7 +337,7 @@ class Setup(Widget):
elif result == 0:
self.state = SetupState.SOFTWARE_SELECTION
self.keyboard.reset()
self.keyboard.reset(min_text_size=1)
self.keyboard.set_title("Enter URL", "for Custom Software")
gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
@@ -343,7 +351,7 @@ class Setup(Widget):
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(1)
time.sleep(0.1)
gui_app.request_close()
else:
self.state = SetupState.NETWORK_SETUP
@@ -369,7 +377,9 @@ class Setup(Widget):
fd, tmpfile = tempfile.mkstemp(prefix="installer_")
headers = {"User-Agent": USER_AGENT, "X-openpilot-serial": HARDWARE.get_serial()}
headers = {"User-Agent": USER_AGENT,
"X-openpilot-serial": HARDWARE.get_serial(),
"X-openpilot-device-type": HARDWARE.get_device_type()}
req = urllib.request.Request(self.download_url, headers=headers)
with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
@@ -406,9 +416,13 @@ class Setup(Widget):
f.write(self.download_url)
# give time for installer UI to take over
time.sleep(5)
time.sleep(0.1)
gui_app.request_close()
except urllib.error.HTTPError as e:
if e.code == 409:
error_msg = e.read().decode("utf-8")
self.download_failed(self.download_url, error_msg)
except Exception:
error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
self.download_failed(self.download_url, error_msg)
@@ -423,8 +437,9 @@ def main():
try:
gui_app.init_window("Setup", 20)
setup = Setup()
for _ in gui_app.render():
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
for should_render in gui_app.render():
if should_render:
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
setup.close()
except Exception as e:
print(f"Setup error: {e}")
+13 -10
View File
@@ -3,19 +3,22 @@ from importlib.resources import as_file, files
ASSETS_DIR_SP = files("openpilot.sunnypilot.selfdrive").joinpath("assets")
class GuiApplicationSP(GuiApplication):
def __init__(self, width: int, height: int):
super().__init__(width, height)
def __init__(self, width: int, height: int):
super().__init__(width, height)
def sp_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:
return self._textures[cache_key]
def sp_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:
return self._textures[cache_key]
with as_file(ASSETS_DIR_SP.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
with as_file(ASSETS_DIR_SP.joinpath(asset_path)) as fspath:
texture_obj = self._load_texture_from_image(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
self._textures[cache_key] = texture_obj
return texture_obj
gui_app_sp = GuiApplicationSP(2160, 1080)
+15 -12
View File
@@ -7,7 +7,7 @@ from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle
from openpilot.system.ui.widgets.button import Button, ButtonStyle
MARGIN = 50
SPACING = 40
@@ -53,27 +53,30 @@ class TextWindow(Widget):
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)
self._scroll_panel = GuiScrollPanel()
self._scroll_panel._offset_filter_y.x = -max(self._content_rect.height - self._textarea_rect.height, 0)
button_text = "Exit" if PC else "Reboot"
self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER)
@staticmethod
def _on_button_clicked():
gui_app.request_close()
if not PC:
HARDWARE.reboot()
def _render(self, rect: rl.Rectangle):
scroll = self._scroll_panel.handle_scroll(self._textarea_rect, self._content_rect)
scroll = self._scroll_panel.update(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):
position = rl.Vector2(self._textarea_rect.x + scroll.x, self._textarea_rect.y + scroll.y + i * LINE_HEIGHT)
position = rl.Vector2(self._textarea_rect.x, self._textarea_rect.y + scroll + i * LINE_HEIGHT)
if position.y + LINE_HEIGHT < self._textarea_rect.y or position.y > self._textarea_rect.y + self._textarea_rect.height:
continue
rl.draw_text_ex(gui_app.font(), line, position, FONT_SIZE, 0, rl.WHITE)
rl.end_scissor_mode()
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:
gui_app.request_close()
else:
HARDWARE.reboot()
return ret
self._button.render(button_bounds)
if __name__ == "__main__":
+26 -22
View File
@@ -6,10 +6,10 @@ import pyray as rl
from enum import IntEnum
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_text_box, gui_label
from openpilot.system.ui.widgets.network import WifiManagerUI
@@ -45,8 +45,17 @@ class Updater(Widget):
self.update_thread = None
self.wifi_manager_ui = WifiManagerUI(WifiManager())
# Buttons
self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
def set_current_screen(self, screen: Screen):
self.current_screen = screen
def install_update(self):
self.current_screen = Screen.PROGRESS
self.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "Downloading..."
self.show_reboot_button = False
@@ -80,14 +89,14 @@ class Updater(Widget):
def render_prompt_screen(self, rect: rl.Rectangle):
# Title
title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE)
title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
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.")
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * 3)
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
# Buttons at the bottom
@@ -96,25 +105,22 @@ class Updater(Widget):
# WiFi button
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
if gui_button(wifi_button_rect, "Connect to Wi-Fi"):
self.current_screen = Screen.WIFI
return # Return to avoid processing other buttons after screen change
self._wifi_button.render(wifi_button_rect)
# Install button
install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
if gui_button(install_button_rect, "Install", button_style=ButtonStyle.PRIMARY):
self.install_update()
return # Return to avoid further processing after action
self._install_button.render(install_button_rect)
def render_wifi_screen(self, rect: rl.Rectangle):
# Draw the Wi-Fi manager UI
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)
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
rect.height - BUTTON_HEIGHT - MARGIN * 3)
rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
self.wifi_manager_ui.render(wifi_content_rect)
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
self._back_button.render(back_button_rect)
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
@@ -133,10 +139,7 @@ class Updater(Widget):
# Show reboot button if needed
if self.show_reboot_button:
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
self._reboot_button.render(reboot_rect)
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
@@ -158,8 +161,9 @@ def main():
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for _ in gui_app.render():
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
for should_render in gui_app.render():
if should_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()
+36 -17
View File
@@ -14,13 +14,14 @@ class DialogResult(IntEnum):
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] * MAX_TOUCH_SLOTS
self._parent_rect: rl.Rectangle | None = None
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
# if current mouse/touch down started within the widget's rectangle
self._tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self._enabled: bool | Callable[[], bool] = True
self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False
@property
@@ -40,7 +41,7 @@ class Widget(abc.ABC):
@property
def is_pressed(self) -> bool:
return any(self._is_pressed)
return any(self.__is_pressed)
@property
def enabled(self) -> bool:
@@ -56,6 +57,10 @@ class Widget(abc.ABC):
def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
"""Set a callback to be called when the widget is clicked."""
self._click_callback = click_callback
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
@@ -70,6 +75,13 @@ class Widget(abc.ABC):
if changed:
self._update_layout_rects()
@property
def _hit_rect(self) -> rl.Rectangle:
# restrict touches to within parent rect if set, useful inside Scroller
if self._parent_rect is None:
return self._rect
return rl.get_collision_rec(self._rect, self._parent_rect)
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
if rect is not None:
self.set_rect(rect)
@@ -90,29 +102,30 @@ class Widget(abc.ABC):
# Ignores touches/presses that start outside our rect
# Allows touch to leave the rect and come back in focus if mouse did not release
if mouse_event.left_pressed and self._touch_valid():
if rl.check_collision_point_rec(mouse_event.pos, self._rect):
self._is_pressed[mouse_event.slot] = True
self._tracking_is_pressed[mouse_event.slot] = True
if rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
self.__tracking_is_pressed[mouse_event.slot] = True
# Callback such as scroll panel signifies user is scrolling
elif not self._touch_valid():
self._is_pressed[mouse_event.slot] = False
self._tracking_is_pressed[mouse_event.slot] = False
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released:
if self._is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._rect):
if self.__is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self._handle_mouse_release(mouse_event.pos)
self._is_pressed[mouse_event.slot] = False
self._tracking_is_pressed[mouse_event.slot] = False
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
# Mouse/touch is still within our rect
elif rl.check_collision_point_rec(mouse_event.pos, self._rect):
if self._tracking_is_pressed[mouse_event.slot]:
self._is_pressed[mouse_event.slot] = True
elif rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
if self.__tracking_is_pressed[mouse_event.slot]:
self.__is_pressed[mouse_event.slot] = True
# Mouse/touch left our rect but may come back into focus later
elif not rl.check_collision_point_rec(mouse_event.pos, self._rect):
self._is_pressed[mouse_event.slot] = False
elif not rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self.__is_pressed[mouse_event.slot] = False
return ret
@@ -126,8 +139,14 @@ class Widget(abc.ABC):
def _update_layout_rects(self) -> None:
"""Optionally update any layout rects on Widget rect change."""
def _handle_mouse_press(self, mouse_pos: MousePos) -> bool:
"""Optionally handle mouse press events."""
return False
def _handle_mouse_release(self, mouse_pos: MousePos) -> bool:
"""Optionally handle mouse release events."""
if self._click_callback:
self._click_callback()
return False
def show_event(self):
+51 -119
View File
@@ -3,10 +3,9 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import TextAlignment, Label
from openpilot.system.ui.widgets.label import Label
class ButtonStyle(IntEnum):
@@ -14,6 +13,8 @@ class ButtonStyle(IntEnum):
PRIMARY = 1 # For main actions
DANGER = 2 # For critical actions, like reboot or delete
TRANSPARENT = 3 # For buttons with transparent background and border
TRANSPARENT_WHITE_TEXT = 9 # For buttons with transparent background and border and white text
TRANSPARENT_WHITE_BORDER = 10 # For buttons with transparent background and white border and text
ACTION = 4
LIST_ACTION = 5 # For list items with action buttons
NO_EFFECT = 6
@@ -23,8 +24,6 @@ class ButtonStyle(IntEnum):
ICON_PADDING = 15
DEFAULT_BUTTON_FONT_SIZE = 60
BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51)
BUTTON_DISABLED_BACKGROUND_COLOR = rl.Color(51, 51, 51, 255)
ACTION_BUTTON_FONT_SIZE = 48
BUTTON_TEXT_COLOR = {
@@ -32,6 +31,8 @@ BUTTON_TEXT_COLOR = {
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE,
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.Color(228, 228, 228, 255),
ButtonStyle.ACTION: rl.BLACK,
ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255),
ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255),
@@ -39,11 +40,17 @@ BUTTON_TEXT_COLOR = {
ButtonStyle.FORGET_WIFI: rl.Color(51, 51, 51, 255),
}
BUTTON_DISABLED_TEXT_COLORS = {
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE,
}
BUTTON_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255),
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.DANGER: rl.Color(226, 44, 44, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLACK,
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
@@ -56,6 +63,8 @@ BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255),
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLANK,
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74),
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
@@ -63,116 +72,23 @@ BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255),
}
_pressed_buttons: set[str] = set() # Track mouse press state globally
# TODO: This should be a Widget class
def gui_button(
rect: rl.Rectangle,
text: str,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL,
is_enabled: bool = True,
border_radius: int = 10, # Corner rounding in pixels
text_alignment: TextAlignment = TextAlignment.CENTER,
text_padding: int = 20, # Padding for left/right alignment
icon=None,
) -> int:
button_id = f"{rect.x}_{rect.y}_{rect.width}_{rect.height}"
result = 0
if button_style in (ButtonStyle.PRIMARY, ButtonStyle.DANGER) and not is_enabled:
button_style = ButtonStyle.NORMAL
if button_style == ButtonStyle.ACTION and font_size == DEFAULT_BUTTON_FONT_SIZE:
font_size = ACTION_BUTTON_FONT_SIZE
# Set background color based on button type
bg_color = BUTTON_BACKGROUND_COLORS[button_style]
mouse_over = is_enabled and rl.check_collision_point_rec(rl.get_mouse_position(), rect)
is_pressed = button_id in _pressed_buttons
if mouse_over:
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
# Only this button enters pressed state
_pressed_buttons.add(button_id)
is_pressed = True
# Use pressed color when mouse is down over this button
if is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[button_style]
# Handle button click
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and is_pressed:
result = 1
_pressed_buttons.remove(button_id)
# Clean up pressed state if mouse is released anywhere
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and button_id in _pressed_buttons:
_pressed_buttons.remove(button_id)
# Draw the button with rounded corners
roundness = border_radius / (min(rect.width, rect.height) / 2)
if button_style != ButtonStyle.TRANSPARENT:
rl.draw_rectangle_rounded(rect, roundness, 20, bg_color)
else:
rl.draw_rectangle_rounded(rect, roundness, 20, rl.BLACK)
rl.draw_rectangle_rounded_lines_ex(rect, roundness, 20, 2, rl.WHITE)
# Handle icon and text positioning
font = gui_app.font(font_weight)
text_size = measure_text_cached(font, text, font_size)
text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering
# Draw icon if provided
if icon:
icon_y = rect.y + (rect.height - icon.height) / 2
if text:
if text_alignment == TextAlignment.LEFT:
icon_x = rect.x + text_padding
text_pos.x = icon_x + icon.width + ICON_PADDING
elif text_alignment == TextAlignment.CENTER:
total_width = icon.width + ICON_PADDING + text_size.x
icon_x = rect.x + (rect.width - total_width) / 2
text_pos.x = icon_x + icon.width + ICON_PADDING
else: # RIGHT
text_pos.x = rect.x + rect.width - text_size.x - text_padding
icon_x = text_pos.x - ICON_PADDING - icon.width
else:
# Center icon when no text
icon_x = rect.x + (rect.width - icon.width) / 2
rl.draw_texture_v(icon, rl.Vector2(icon_x, icon_y), rl.WHITE if is_enabled else rl.Color(255, 255, 255, 100))
else:
# No icon, position text normally
if text_alignment == TextAlignment.LEFT:
text_pos.x = rect.x + text_padding
elif text_alignment == TextAlignment.CENTER:
text_pos.x = rect.x + (rect.width - text_size.x) // 2
elif text_alignment == TextAlignment.RIGHT:
text_pos.x = rect.x + rect.width - text_size.x - text_padding
# Draw the button text if any
if text:
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
BUTTON_DISABLED_BACKGROUND_COLORS = {
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
}
class Button(Widget):
def __init__(self,
text: str,
click_callback: Callable[[], None] = None,
text: str | Callable[[], str],
click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL,
border_radius: int = 10,
text_alignment: TextAlignment = TextAlignment.CENTER,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_padding: int = 20,
icon = None,
icon=None,
elide_right: bool = False,
multi_touch: bool = False,
):
@@ -181,8 +97,8 @@ class Button(Widget):
self._border_radius = border_radius
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
self._label = Label(text, font_size, font_weight, text_alignment, text_padding,
BUTTON_TEXT_COLOR[self._button_style], icon=icon)
self._label = Label(text, font_size, font_weight, text_alignment, text_padding=text_padding,
text_color=BUTTON_TEXT_COLOR[self._button_style], icon=icon, elide_right=elide_right)
self._click_callback = click_callback
self._multi_touch = multi_touch
@@ -190,9 +106,10 @@ class Button(Widget):
def set_text(self, text):
self._label.set_text(text)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._click_callback and self.enabled:
self._click_callback()
def set_button_style(self, button_style: ButtonStyle):
self._button_style = button_style
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
def _update_state(self):
if self.enabled:
@@ -202,12 +119,16 @@ class Button(Widget):
else:
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
elif self._button_style != ButtonStyle.NO_EFFECT:
self._background_color = BUTTON_DISABLED_BACKGROUND_COLOR
self._label.set_text_color(BUTTON_DISABLED_TEXT_COLOR)
self._background_color = BUTTON_DISABLED_BACKGROUND_COLORS.get(self._button_style, rl.Color(51, 51, 51, 255))
self._label.set_text_color(BUTTON_DISABLED_TEXT_COLORS.get(self._button_style, rl.Color(228, 228, 228, 51)))
def _render(self, _):
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
if self._button_style == ButtonStyle.TRANSPARENT_WHITE_BORDER:
rl.draw_rectangle_rounded(self._rect, roundness, 10, rl.BLACK)
rl.draw_rectangle_rounded_lines_ex(self._rect, roundness, 10, 2, rl.WHITE)
else:
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
self._label.render(self._rect)
@@ -215,9 +136,9 @@ class ButtonRadio(Button):
def __init__(self,
text: str,
icon,
click_callback: Callable[[], None] = None,
click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
text_alignment: TextAlignment = TextAlignment.LEFT,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
border_radius: int = 10,
text_padding: int = 20,
):
@@ -230,9 +151,8 @@ class ButtonRadio(Button):
self.selected = False
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.selected = not self.selected
if self._click_callback:
self._click_callback()
def _update_state(self):
if self.selected:
@@ -249,3 +169,15 @@ class ButtonRadio(Button):
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
icon_x = self._rect.x + self._rect.width - self._icon.width - self._text_padding - ICON_PADDING
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE if self.enabled else rl.Color(255, 255, 255, 100))
class IconButton(Widget):
def __init__(self, texture: rl.Texture):
super().__init__()
self._texture = texture
def _render(self, rect: rl.Rectangle):
color = rl.Color(180, 180, 180, 150) if self.is_pressed else rl.WHITE
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(self._texture, int(draw_x), int(draw_y), color)
+41 -69
View File
@@ -1,28 +1,40 @@
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle, Button
from openpilot.system.ui.widgets.label import gui_text_box, Label
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.scroller import Scroller
DIALOG_WIDTH = 1520
DIALOG_HEIGHT = 600
OUTER_MARGIN = 200
RICH_OUTER_MARGIN = 100
BUTTON_HEIGHT = 160
MARGIN = 50
TEXT_AREA_HEIGHT_REDUCTION = 200
TEXT_PADDING = 10
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
class ConfirmDialog(Widget):
def __init__(self, text: str, confirm_text: str, cancel_text: str = "Cancel"):
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False):
super().__init__()
self._label = Label(text, 70, FontWeight.BOLD)
if cancel_text is None:
cancel_text = tr("Cancel")
self._label = Label(text, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255))
self._html_renderer = HtmlRenderer(text=text, text_size={ElementType.P: 50}, center_text=True)
self._cancel_button = Button(cancel_text, self._cancel_button_callback)
self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._rich = rich
self._dialog_result = DialogResult.NO_ACTION
self._cancel_text = cancel_text
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
def set_text(self, text):
self._label.set_text(text)
if not self._rich:
self._label.set_text(text)
else:
self._html_renderer.parse_html_content(text)
def reset(self):
self._dialog_result = DialogResult.NO_ACTION
@@ -34,9 +46,11 @@ class ConfirmDialog(Widget):
self._dialog_result = DialogResult.CONFIRM
def _render(self, rect: rl.Rectangle):
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)
dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
dialog_y = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
dialog_width = gui_app.width - 2 * dialog_x
dialog_height = gui_app.height - 2 * dialog_y
dialog_rect = rl.Rectangle(dialog_x, dialog_y, dialog_width, dialog_height)
bottom = dialog_rect.y + dialog_rect.height
button_width = (dialog_rect.width - 3 * MARGIN) // 2
@@ -48,8 +62,15 @@ class ConfirmDialog(Widget):
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y, dialog_rect.width - 2 * MARGIN, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION)
self._label.render(text_rect)
text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + TEXT_PADDING,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - BUTTON_HEIGHT - MARGIN - TEXT_PADDING * 2)
if not self._rich:
self._label.render(text_rect)
else:
html_rect = rl.Rectangle(text_rect.x, text_rect.y, text_rect.width,
self._html_renderer.get_total_height(int(text_rect.width)))
self._html_renderer.set_rect(html_rect)
self._scroller.render(text_rect)
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
self._dialog_result = DialogResult.CONFIRM
@@ -60,63 +81,14 @@ class ConfirmDialog(Widget):
self._confirm_button.render(confirm_button)
self._cancel_button.render(cancel_button)
else:
centered_button_x = dialog_rect.x + (dialog_rect.width - button_width) / 2
centered_confirm_button = rl.Rectangle(centered_button_x, button_y, button_width, BUTTON_HEIGHT)
self._confirm_button.render(centered_confirm_button)
full_button_width = dialog_rect.width - 2 * MARGIN
full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT)
self._confirm_button.render(full_confirm_button)
return self._dialog_result
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)
# Calculate button positions at the bottom of the dialog
bottom = dialog_rect.y + dialog_rect.height
button_width = (dialog_rect.width - 3 * MARGIN) // 2
no_button_x = dialog_rect.x + MARGIN
yes_button_x = dialog_rect.x + dialog_rect.width - button_width - MARGIN
button_y = bottom - BUTTON_HEIGHT - MARGIN
no_button = rl.Rectangle(no_button_x, button_y, button_width, BUTTON_HEIGHT)
yes_button = rl.Rectangle(yes_button_x, button_y, button_width, BUTTON_HEIGHT)
# Draw the dialog background
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
# Draw the message in the dialog, centered
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=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 = DialogResult.NO_ACTION
# Check for keyboard input for accessibility
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
result = DialogResult.CONFIRM
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
result = DialogResult.CANCEL
# Check for button clicks
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="")
def alert_dialog(message: str, button_text: str | None = None):
if button_text is None:
button_text = tr("OK")
return ConfirmDialog(message, button_text, cancel_text="")
+167 -71
View File
@@ -3,11 +3,15 @@ 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.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.lib.text_measure import measure_text_cached
LIST_INDENT_PX = 40
class ElementType(Enum):
@@ -18,40 +22,80 @@ class ElementType(Enum):
H5 = "h5"
H6 = "h6"
P = "p"
B = "b"
UL = "ul"
LI = "li"
BR = "br"
TAG_NAMES = '|'.join([t.value for t in ElementType])
START_TAG_RE = re.compile(f'<({TAG_NAMES})>')
END_TAG_RE = re.compile(f'</({TAG_NAMES})>')
COMMENT_RE = re.compile(r'<!--.*?-->', flags=re.DOTALL)
DOCTYPE_RE = re.compile(r'<!DOCTYPE[^>]*>')
HTML_BODY_TAGS_RE = re.compile(r'</?(?:html|head|body)[^>]*>')
TOKEN_RE = re.compile(r'</[^>]+>|<[^>]+>|[^<\s]+')
def is_tag(token: str) -> tuple[bool, bool, ElementType | None]:
supported_tag = bool(START_TAG_RE.fullmatch(token))
supported_end_tag = bool(END_TAG_RE.fullmatch(token))
tag = ElementType(token[1:-1].strip('/')) if supported_tag or supported_end_tag else None
return supported_tag, supported_end_tag, tag
@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
line_height: float = 0.9 # matches Qt visually, unsure why not default 1.2
indent_level: int = 0
class HtmlRenderer(Widget):
def __init__(self, file_path: str):
self.elements: list[HtmlElement] = []
def __init__(self, file_path: str | None = None, text: str | None = None,
text_size: dict | None = None, text_color: rl.Color = rl.WHITE, center_text: bool = False):
super().__init__()
self._text_color = text_color
self._center_text = center_text
self._normal_font = gui_app.font(FontWeight.NORMAL)
self._bold_font = gui_app.font(FontWeight.BOLD)
self._scroll_panel = GuiScrollPanel()
self._indent_level = 0
if text_size is None:
text_size = {}
self._cached_height: float | None = None
self._cached_width: int = -1
# Base paragraph size (Qt stylesheet default is 48px in offroad alerts)
base_p_size = int(text_size.get(ElementType.P, 48))
# Untagged text defaults to <p>
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},
ElementType.H1: {"size": round(base_p_size * 2), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 16},
ElementType.H2: {"size": round(base_p_size * 1.50), "weight": FontWeight.BOLD, "margin_top": 24, "margin_bottom": 12},
ElementType.H3: {"size": round(base_p_size * 1.17), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 10},
ElementType.H4: {"size": round(base_p_size * 1.00), "weight": FontWeight.BOLD, "margin_top": 16, "margin_bottom": 8},
ElementType.H5: {"size": round(base_p_size * 0.83), "weight": FontWeight.BOLD, "margin_top": 12, "margin_bottom": 6},
ElementType.H6: {"size": round(base_p_size * 0.67), "weight": FontWeight.BOLD, "margin_top": 10, "margin_bottom": 4},
ElementType.P: {"size": base_p_size, "weight": FontWeight.NORMAL, "margin_top": 8, "margin_bottom": 12},
ElementType.B: {"size": base_p_size, "weight": FontWeight.BOLD, "margin_top": 8, "margin_bottom": 12},
ElementType.LI: {"size": base_p_size, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 6, "margin_bottom": 6},
ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "margin_top": 0, "margin_bottom": 12},
}
self.parse_html_file(file_path)
self.elements: list[HtmlElement] = []
if file_path is not None:
self.parse_html_file(file_path)
elif text is not None:
self.parse_html_content(text)
else:
raise ValueError("Either file_path or text must be provided")
def parse_html_file(self, file_path: str) -> None:
with open(file_path, encoding='utf-8') as file:
@@ -60,33 +104,62 @@ class HtmlRenderer(Widget):
def parse_html_content(self, html_content: str) -> None:
self.elements.clear()
self._cached_height = None
self._cached_width = -1
# Remove HTML comments
html_content = re.sub(r'<!--.*?-->', '', html_content, flags=re.DOTALL)
html_content = COMMENT_RE.sub('', html_content)
# 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)
html_content = DOCTYPE_RE.sub('', html_content)
html_content = HTML_BODY_TAGS_RE.sub('', 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)
# Parse HTML
tokens = TOKEN_RE.findall(html_content)
def close_tag():
nonlocal current_content
nonlocal current_tag
# If no tag is set, default to paragraph so we don't lose text
if current_tag is None:
current_tag = ElementType.P
text = ' '.join(current_content).strip()
current_content = []
if text:
if current_tag == ElementType.LI:
text = '' + text
self._add_element(current_tag, text)
current_content: list[str] = []
current_tag: ElementType | None = None
for token in tokens:
is_start_tag, is_end_tag, tag = is_tag(token)
if tag is not None:
if tag == ElementType.BR:
# Close current tag and add a line break
close_tag()
self._add_element(ElementType.BR, "")
elif is_start_tag or is_end_tag:
# Always add content regardless of opening or closing tag
close_tag()
if is_start_tag:
current_tag = tag
else:
current_tag = None
# increment after we add the content for the current tag
if tag == ElementType.UL:
self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1)
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()
current_content.append(token)
# 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)
if current_content:
close_tag()
def _add_element(self, element_type: ElementType, content: str) -> None:
style = self.styles[element_type]
@@ -96,42 +169,16 @@ class HtmlRenderer(Widget):
content=content,
font_size=style["size"],
font_weight=style["weight"],
color=style["color"],
margin_top=style["margin_top"],
margin_bottom=style["margin_bottom"],
indent_level=self._indent_level,
)
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
# TODO: speed up by removing duplicate calculations across renders
current_y = rect.y
padding = 20
content_width = rect.width - (padding * 2)
@@ -149,23 +196,33 @@ class HtmlRenderer(Widget):
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
# Use FONT_SCALE from wrapped raylib text functions to match what is drawn
if current_y < rect.y - element.font_size * FONT_SCALE:
current_y += element.font_size * FONT_SCALE * 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)
if self._center_text:
text_width = measure_text_cached(font, line, element.font_size).x
text_x = rect.x + (rect.width - text_width) / 2
else: # left align
text_x = rect.x + (max(element.indent_level - 1, 0) * LIST_INDENT_PX)
current_y += element.font_size * element.line_height
rl.draw_text_ex(font, line, rl.Vector2(text_x + padding, current_y), element.font_size, 0, self._text_color)
current_y += element.font_size * FONT_SCALE * element.line_height
# Apply bottom margin
current_y += element.margin_bottom
return current_y - rect.y - scroll_offset # Return total content height
return current_y - rect.y
def get_total_height(self, content_width: int) -> float:
if self._cached_height is not None and self._cached_width == content_width:
return self._cached_height
total_height = 0.0
padding = 20
usable_width = content_width - (padding * 2)
@@ -182,13 +239,52 @@ class HtmlRenderer(Widget):
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.font_size * FONT_SCALE * element.line_height
total_height += element.margin_bottom
# Store result in cache
self._cached_height = total_height
self._cached_width = content_width
return total_height
def _get_font(self, weight: FontWeight):
if weight == FontWeight.BOLD:
return self._bold_font
return self._normal_font
class HtmlModal(Widget):
def __init__(self, file_path: str | None = None, text: str | None = None):
super().__init__()
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel()
self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY)
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._content.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.update(scrollable_rect, scroll_content_rect)
scroll_content_rect.y += scroll_offset
rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height))
self._content.render(scroll_content_rect)
rl.end_scissor_mode()
button_width = (rect.width - 3 * 50) // 3
button_x = content_rect.x + content_rect.width - button_width
button_y = content_rect.y + content_rect.height - button_height
button_rect = rl.Rectangle(button_x, button_y, button_width, button_height)
self._ok_button.render(button_rect)
return -1
+3 -3
View File
@@ -1,6 +1,6 @@
import pyray as rl
import time
from openpilot.system.ui.lib.application import gui_app, MousePos
from openpilot.system.ui.lib.application import gui_app, MousePos, FONT_SCALE
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
@@ -130,7 +130,7 @@ class InputBox(Widget):
rl.draw_text_ex(
font,
display_text,
rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size / 2)),
rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size * FONT_SCALE / 2)),
font_size,
0,
text_color,
@@ -145,7 +145,7 @@ class InputBox(Widget):
# Apply text offset to cursor position
cursor_x -= self._text_offset
cursor_height = font_size + 4
cursor_height = font_size * FONT_SCALE + 4
cursor_y = rect.y + rect.height / 2 - cursor_height / 2
rl.draw_line(int(cursor_x), int(cursor_y), int(cursor_x), int(cursor_y + cursor_height), rl.WHITE)
+19 -9
View File
@@ -5,10 +5,11 @@ from typing import Literal
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.inputbox import InputBox
from openpilot.system.ui.widgets.label import Label, TextAlignment
from openpilot.system.ui.widgets.label import Label
KEY_FONT_SIZE = 96
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
@@ -19,7 +20,7 @@ DELETE_REPEAT_INTERVAL = 0.07
CONTENT_MARGIN = 50
BACKSPACE_KEY = "<-"
ENTER_KEY = "->"
SPACE_KEY = " "
SPACE_KEY = " "
SHIFT_INACTIVE_KEY = "SHIFT_OFF"
SHIFT_ACTIVE_KEY = "SHIFT_ON"
CAPS_LOCK_KEY = "CAPS"
@@ -44,13 +45,13 @@ KEYBOARD_LAYOUTS = {
"numbers": [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
[SYMBOL_KEY, ".", ",", "?", "!", "`", BACKSPACE_KEY],
[SYMBOL_KEY, "_", ",", "?", "!", "`", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
"specials": [
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", ""],
[NUMERIC_KEY, ".", ",", "?", "!", "'", BACKSPACE_KEY],
[NUMERIC_KEY, "-", ",", "?", "!", "'", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
}
@@ -62,8 +63,8 @@ class Keyboard(Widget):
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
self._caps_lock = False
self._last_shift_press_time = 0
self._title = Label("", 90, FontWeight.BOLD, TextAlignment.LEFT)
self._sub_title = Label("", 55, FontWeight.NORMAL, TextAlignment.LEFT)
self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._max_text_size = max_text_size
self._min_text_size = min_text_size
@@ -77,7 +78,7 @@ class Keyboard(Widget):
self._backspace_last_repeat: float = 0.0
self._render_return_status = -1
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
@@ -98,12 +99,15 @@ class Keyboard(Widget):
if key in self._key_icons:
texture = self._key_icons[key]
self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture,
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True)
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True)
else:
self._all_keys[key] = Button(key, partial(self._key_callback, key), button_style=ButtonStyle.KEYBOARD, font_size=85, multi_touch=True)
self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY],
button_style=ButtonStyle.KEYBOARD, multi_touch=True)
def set_text(self, text: str):
self._input_box.text = text
@property
def text(self):
return self._input_box.text
@@ -243,8 +247,14 @@ class Keyboard(Widget):
if not self._caps_lock and self._layout_name == "uppercase":
self._layout_name = "lowercase"
def reset(self):
def reset(self, min_text_size: int | None = None):
if min_text_size is not None:
self._min_text_size = min_text_size
self._render_return_status = -1
self._last_shift_press_time = 0
self._backspace_pressed = False
self._backspace_press_time = 0.0
self._backspace_last_repeat = 0.0
self.clear()
+80 -37
View File
@@ -1,9 +1,9 @@
from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
from typing import Union
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.utils import GuiStyleContext
from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex
@@ -12,10 +12,13 @@ from openpilot.system.ui.widgets import Widget
ICON_PADDING = 15
class TextAlignment(IntEnum):
LEFT = 0
CENTER = 1
RIGHT = 2
# TODO: make this common
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
# TODO: This should be a Widget class
def gui_label(
@@ -34,17 +37,17 @@ def gui_label(
# Elide text to fit within the rectangle
if elide_right and text_size.x > rect.width:
ellipsis = "..."
_ellipsis = "..."
left, right = 0, len(text)
while left < right:
mid = (left + right) // 2
candidate = text[:mid] + ellipsis
candidate = text[:mid] + _ellipsis
candidate_size = measure_text_cached(font, candidate, font_size)
if candidate_size.x <= rect.width:
left = mid + 1
else:
right = mid
display_text = text[: left - 1] + ellipsis if left > 0 else ellipsis
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
text_size = measure_text_cached(font, display_text, font_size)
# Calculate horizontal position based on alignment
@@ -76,8 +79,8 @@ def gui_text_box(
):
styles = [
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, font_size),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, font_size),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE)),
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
@@ -95,13 +98,15 @@ def gui_text_box(
# Non-interactive text area. Can render emojis and an optional specified icon.
class Label(Widget):
def __init__(self,
text: str,
text: str | Callable[[], str],
font_size: int = DEFAULT_TEXT_SIZE,
font_weight: FontWeight = FontWeight.NORMAL,
text_alignment: TextAlignment = TextAlignment.CENTER,
text_padding: int = 20,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
text_padding: int = 0,
text_color: rl.Color = DEFAULT_TEXT_COLOR,
icon = None,
icon: Union[rl.Texture, None] = None, # noqa: UP007
elide_right: bool = False,
):
super().__init__()
@@ -109,41 +114,79 @@ class Label(Widget):
self._font = gui_app.font(self._font_weight)
self._font_size = font_size
self._text_alignment = text_alignment
self._text_alignment_vertical = text_alignment_vertical
self._text_padding = text_padding
self._text_color = text_color
self._icon = icon
self._elide_right = elide_right
self._text = text
self.set_text(text)
def set_text(self, text):
self._text_raw = text
self._update_text(self._text_raw)
self._text = text
self._update_text(self._text)
def set_text_color(self, color):
self._text_color = color
def _update_layout_rects(self):
self._update_text(self._text_raw)
def set_font_size(self, size):
self._font_size = size
self._update_text(self._text)
def _update_text(self, text):
self._emojis = []
self._text_size = []
self._text = wrap_text(self._font, text, self._font_size, self._rect.width - (self._text_padding*2))
for t in self._text:
text = _resolve_value(text)
if self._elide_right:
display_text = text
# Elide text to fit within the rectangle
text_size = measure_text_cached(self._font, text, self._font_size)
content_width = self._rect.width - self._text_padding * 2
if self._icon:
content_width -= self._icon.width + ICON_PADDING
if text_size.x > content_width:
_ellipsis = "..."
left, right = 0, len(text)
while left < right:
mid = (left + right) // 2
candidate = text[:mid] + _ellipsis
candidate_size = measure_text_cached(self._font, candidate, self._font_size)
if candidate_size.x <= content_width:
left = mid + 1
else:
right = mid
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
self._text_wrapped = [display_text]
else:
self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2)))
for t in self._text_wrapped:
self._emojis.append(find_emoji(t))
self._text_size.append(measure_text_cached(self._font, t, self._font_size))
def _render(self, _):
text = self._text[0] if self._text else None
# Text can be a callable
# TODO: cache until text changed
self._update_text(self._text)
text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0)
text_pos = rl.Vector2(0, (self._rect.y + (self._rect.height - (text_size.y)) // 2))
if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE:
total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE
text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2))
else:
text_pos = rl.Vector2(self._rect.x, self._rect.y)
if self._icon:
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
if text:
if self._text_alignment == TextAlignment.LEFT:
if len(self._text_wrapped) > 0:
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
icon_x = self._rect.x + self._text_padding
text_pos.x = self._icon.width + ICON_PADDING
elif self._text_alignment == TextAlignment.CENTER:
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
total_width = self._icon.width + ICON_PADDING + text_size.x
icon_x = self._rect.x + (self._rect.width - total_width) / 2
text_pos.x = self._icon.width + ICON_PADDING
@@ -153,14 +196,14 @@ class Label(Widget):
icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE)
for text, text_size, emojis in zip_longest(self._text, self._text_size, self._emojis, fillvalue=[]):
for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]):
line_pos = rl.Vector2(text_pos.x, text_pos.y)
if self._text_alignment == TextAlignment.LEFT:
line_pos.x += self._rect.x + self._text_padding
elif self._text_alignment == TextAlignment.CENTER:
line_pos.x += self._rect.x + (self._rect.width - text_size.x) // 2
elif self._text_alignment == TextAlignment.RIGHT:
line_pos.x += self._rect.x + self._rect.width - text_size.x - self._text_padding
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
line_pos.x += self._text_padding
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
line_pos.x += (self._rect.width - text_size.x) // 2
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
line_pos.x += self._rect.width - text_size.x - self._text_padding
prev_index = 0
for start, end, emoji in emojis:
@@ -170,8 +213,8 @@ class Label(Widget):
line_pos.x += width_before.x
tex = emoji_tex(emoji)
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height, self._text_color)
line_pos.x += self._font_size
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
text_pos.y += text_size.y or self._font_size
text_pos.y += text_size.y or self._font_size * FONT_SCALE
+214 -116
View File
@@ -3,17 +3,20 @@ import pyray as rl
from collections.abc import Callable
from abc import ABC
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr
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.widgets import Widget
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
ITEM_BASE_WIDTH = 600
ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20
ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE
ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255)
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 140
@@ -41,16 +44,23 @@ class ItemAction(Widget, ABC):
self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled
def get_width_hint(self) -> float:
# Return's action ideal width, 0 means use full width
return self._rect.width
def set_enabled(self, enabled: bool | Callable[[], bool]):
self._enabled_source = enabled
@property
def enabled(self):
return _resolve_value(self._enabled_source, False)
class ToggleAction(ItemAction):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
callback: Callable[[bool], None] | None = None):
super().__init__(width, enabled)
self.toggle = Toggle(initial_state=initial_state)
self.state = initial_state
self.toggle = Toggle(initial_state=initial_state, callback=callback)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
@@ -58,36 +68,81 @@ class ToggleAction(ItemAction):
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
clicked = self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT))
return bool(clicked)
def set_state(self, state: bool):
self.state = state
self.toggle.set_state(state)
def get_state(self) -> bool:
return self.state
return self.toggle.get_state()
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
self._value_source: str | Callable[[], str] | None = None
self._pressed = False
self._font = gui_app.font(FontWeight.NORMAL)
def pressed():
self._pressed = True
self._button = Button(
self.text,
font_size=BUTTON_FONT_SIZE,
font_weight=BUTTON_FONT_WEIGHT,
button_style=ButtonStyle.LIST_ACTION,
border_radius=BUTTON_BORDER_RADIUS,
click_callback=pressed,
text_padding=0,
)
self.set_enabled(enabled)
def get_width_hint(self) -> float:
value_text = self.value
if value_text:
text_width = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x
return text_width + BUTTON_WIDTH + TEXT_PADDING
else:
return BUTTON_WIDTH
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self._button.set_touch_valid_callback(touch_callback)
def set_text(self, text: str | Callable[[], str]):
self._text_source = text
def set_value(self, value: str | Callable[[], str]):
self._value_source = value
@property
def text(self):
return _resolve_value(self._text_source, "Error")
return _resolve_value(self._text_source, tr("Error"))
@property
def value(self):
return _resolve_value(self._value_source, "")
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
self._button.set_text(self.text)
self._button.set_enabled(_resolve_value(self.enabled))
button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT)
self._button.render(button_rect)
value_text = self.value
if value_text:
value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)
gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# TODO: just use the generic Widget click callbacks everywhere, no returning from render
pressed = self._pressed
self._pressed = False
return pressed
class TextAction(ItemAction):
@@ -102,30 +157,35 @@ class TextAction(ItemAction):
@property
def text(self):
return _resolve_value(self._text_source, "Error")
return _resolve_value(self._text_source, tr("Error"))
def get_width_hint(self) -> float:
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
return text_width + TEXT_PADDING
def _render(self, rect: rl.Rectangle) -> bool:
current_text = self.text
text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE)
text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color)
gui_label(self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
return False
def get_width(self) -> int:
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
return int(text_width + TEXT_PADDING)
def set_text(self, text: str | Callable[[], str]):
self._text_source = text
class DualButtonAction(ItemAction):
def __init__(self, left_text: str, right_text: str, left_callback: Callable = None,
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], 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
self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0)
self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0)
def _render(self, rect: rl.Rectangle) -> bool:
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.left_button.set_touch_valid_callback(touch_callback)
self.right_button.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle):
button_spacing = 30
button_height = 120
button_width = (rect.width - button_spacing) / 2
@@ -134,40 +194,45 @@ class DualButtonAction(ItemAction):
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)
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
# expand one to full width if other is not visible
if not self.left_button.is_visible:
right_rect.x = rect.x
right_rect.width = rect.width
elif not self.right_button.is_visible:
left_rect.width = rect.width
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
# Render buttons
self.left_button.render(left_rect)
self.right_button.render(right_rect)
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)
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None):
super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, 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)
def _render(self, rect: rl.Rectangle) -> bool:
spacing = 20
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
clicked = -1
def set_selected_button(self, index: int):
if 0 <= index < len(self.buttons):
self.selected_button = index
for i, text in enumerate(self.buttons):
def get_selected_button(self) -> int:
return self.selected_button
def _render(self, rect: rl.Rectangle):
spacing = RIGHT_ITEM_PADDING
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
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)
# 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_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled and self.is_pressed
is_selected = i == self.selected_button
# Button colors
@@ -178,48 +243,60 @@ class MultipleButtonAction(ItemAction):
else:
bg_color = rl.Color(57, 57, 57, 255) # Gray
if not self.enabled:
bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim
# Draw button
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
# Draw text
text = _resolve_value(_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))
text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255)
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color)
# Handle click
if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self.is_pressed:
clicked = i
if clicked >= 0:
self.selected_button = clicked
if self.callback:
self.callback(clicked)
return True
return False
def _handle_mouse_release(self, mouse_pos: MousePos):
spacing = RIGHT_ITEM_PADDING
button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2
for i, _ in enumerate(self.buttons):
button_x = self._rect.x + i * (self.button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
if rl.check_collision_point_rec(mouse_pos, button_rect):
self.selected_button = i
if self.callback:
self.callback(i)
class ListItem(Widget):
def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
def __init__(self, title: str | Callable[[], 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._title = title
self.set_icon(icon)
self._description = description
self.description_visible = description_visible
self.callback = callback
self.description_opened_callback: Callable | None = None
self.action_item = action_item
self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
self._font = gui_app.font(FontWeight.NORMAL)
self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None
self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE},
text_color=ITEM_DESC_TEXT_COLOR)
self._parse_description(self.description)
# 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
self._prev_description: str | None = self.description
def show_event(self):
self._set_description_visible(False)
def set_description_opened_callback(self, callback: Callable) -> None:
self.description_opened_callback = callback
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
@@ -241,11 +318,26 @@ class ListItem(Widget):
# 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._set_description_visible(not self.description_visible)
def _set_description_visible(self, visible: bool):
if self.description and self.description_visible != visible:
self.description_visible = visible
# do callback first in case receiver changes description
if self.description_visible and self.description_opened_callback is not None:
self.description_opened_callback()
# Call _update_state to catch any description changes
self._update_state()
content_width = int(self._rect.width - ITEM_PADDING * 2)
self._rect.height = self.get_item_height(self._font, content_width)
def _update_state(self):
# Detect changes if description is callback
new_description = self.description
if new_description != self._prev_description:
self._parse_description(new_description)
def _render(self, _):
if not self.is_visible:
return
@@ -262,7 +354,7 @@ class ListItem(Widget):
if self.title:
# Draw icon if present
if self.icon:
rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.width) // 2), rl.WHITE)
rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) // 2), rl.WHITE)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
@@ -271,16 +363,16 @@ class ListItem(Widget):
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,
self._wrapped_description,
rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
if self.description_visible:
content_width = int(self._rect.width - ITEM_PADDING * 2)
description_height = self._html_renderer.get_total_height(content_width)
description_rect = rl.Rectangle(
self._rect.x + ITEM_PADDING,
self._rect.y + ITEM_DESC_V_OFFSET,
content_width,
description_height
)
self._html_renderer.render(description_rect)
# Draw right item if present
if self.action_item:
@@ -291,78 +383,84 @@ class ListItem(Widget):
if self.callback:
self.callback()
def get_description(self):
return _resolve_value(self.description, None)
def set_icon(self, icon: str | None):
self.icon = icon
self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None
def set_description(self, description: str | Callable[[], str] | None):
self._description = description
def _parse_description(self, new_desc):
self._html_renderer.parse_html_content(new_desc)
self._prev_description = new_desc
@property
def title(self):
return _resolve_value(self._title, "")
@property
def description(self):
return _resolve_value(self._description, "")
def get_item_height(self, font: rl.Font, max_width: int) -> float:
if not self.is_visible:
return 0
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
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
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
height = float(ITEM_BASE_HEIGHT)
if self.description_visible:
description_height = self._html_renderer.get_total_height(max_width)
height += description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING
return height
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
right_width = self.action_item.rect.width
right_width = self.action_item.get_width_hint()
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)
# Clip width to available space, never overlapping this Item's title
content_width = item_rect.width - (ITEM_PADDING * 2)
title_width = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE).x
right_width = min(content_width - title_width, right_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)
# Factory functions
def simple_item(title: str, callback: Callable | None = None) -> ListItem:
def simple_item(title: str | Callable[[], str], callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback)
def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False,
def toggle_item(title: str | Callable[[], 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)
action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback)
return ListItem(title=title, description=description, action_item=action, icon=icon)
def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
def button_item(title: str | Callable[[], 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 | Callable[[], str], description: str | Callable[[], str] | None = None,
def text_item(title: str | Callable[[], 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)
action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, 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,
def dual_button_item(left_text: str | Callable[[], str], right_text: str | Callable[[], 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,
def multiple_button_item(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], 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)
+277 -42
View File
@@ -4,13 +4,26 @@ from typing import cast
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network
from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import TextAlignment, gui_label
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
try:
from openpilot.common.params import Params
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
except Exception:
Params = None
ui_state = None # type: ignore
PrimeType = None # type: ignore
NM_DEVICE_STATE_NEED_AUTH = 60
MIN_PASSWORD_LENGTH = 8
@@ -26,6 +39,11 @@ STRENGTH_ICONS = [
]
class PanelType(IntEnum):
WIFI = 0
ADVANCED = 1
class UIState(IntEnum):
IDLE = 0
CONNECTING = 1
@@ -34,10 +52,227 @@ class UIState(IntEnum):
FORGETTING = 4
class NavButton(Widget):
def __init__(self, text: str):
super().__init__()
self.text = text
self.set_rect(rl.Rectangle(0, 0, 400, 100))
def _render(self, _):
color = rl.Color(74, 74, 74, 255) if self.is_pressed else rl.Color(57, 57, 57, 255)
rl.draw_rectangle_rounded(self._rect, 0.6, 10, color)
gui_label(self.rect, self.text, font_size=60, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
class NetworkUI(Widget):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self._wifi_manager = wifi_manager
self._current_panel: PanelType = PanelType.WIFI
self._wifi_panel = WifiManagerUI(wifi_manager)
self._advanced_panel = AdvancedNetworkSettings(wifi_manager)
self._nav_button = NavButton(tr("Advanced"))
self._nav_button.set_click_callback(self._cycle_panel)
def show_event(self):
self._set_current_panel(PanelType.WIFI)
self._wifi_panel.show_event()
def hide_event(self):
self._wifi_panel.hide_event()
def _cycle_panel(self):
if self._current_panel == PanelType.WIFI:
self._set_current_panel(PanelType.ADVANCED)
else:
self._set_current_panel(PanelType.WIFI)
def _render(self, _):
# subtract button
content_rect = rl.Rectangle(self._rect.x, self._rect.y + self._nav_button.rect.height + 40,
self._rect.width, self._rect.height - self._nav_button.rect.height - 40)
if self._current_panel == PanelType.WIFI:
self._nav_button.text = tr("Advanced")
self._nav_button.set_position(self._rect.x + self._rect.width - self._nav_button.rect.width, self._rect.y + 20)
self._wifi_panel.render(content_rect)
else:
self._nav_button.text = tr("Back")
self._nav_button.set_position(self._rect.x, self._rect.y + 20)
self._advanced_panel.render(content_rect)
self._nav_button.render()
def _set_current_panel(self, panel: PanelType):
self._current_panel = panel
class AdvancedNetworkSettings(Widget):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self._wifi_manager = wifi_manager
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
self._params = Params()
self._keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
# Tethering
self._tethering_action = ToggleAction(initial_state=False)
tethering_btn = ListItem(lambda: tr("Enable Tethering"), action_item=self._tethering_action, callback=self._toggle_tethering)
# Edit tethering password
self._tethering_password_action = ButtonAction(lambda: tr("EDIT"))
tethering_password_btn = ListItem(lambda: tr("Tethering Password"), action_item=self._tethering_password_action, callback=self._edit_tethering_password)
# Roaming toggle
roaming_enabled = self._params.get_bool("GsmRoaming")
self._roaming_action = ToggleAction(initial_state=roaming_enabled)
self._roaming_btn = ListItem(lambda: tr("Enable Roaming"), action_item=self._roaming_action, callback=self._toggle_roaming)
# Cellular metered toggle
cellular_metered = self._params.get_bool("GsmMetered")
self._cellular_metered_action = ToggleAction(initial_state=cellular_metered)
self._cellular_metered_btn = ListItem(lambda: tr("Cellular Metered"),
description=lambda: tr("Prevent large data uploads when on a metered cellular connection"),
action_item=self._cellular_metered_action, callback=self._toggle_cellular_metered)
# APN setting
self._apn_btn = button_item(lambda: tr("APN Setting"), lambda: tr("EDIT"), callback=self._edit_apn)
# Wi-Fi metered toggle
self._wifi_metered_action = MultipleButtonAction([lambda: tr("default"), lambda: tr("metered"), lambda: tr("unmetered")], 255, 0,
callback=self._toggle_wifi_metered)
wifi_metered_btn = ListItem(lambda: tr("Wi-Fi Network Metered"), description=lambda: tr("Prevent large data uploads when on a metered Wi-Fi connection"),
action_item=self._wifi_metered_action)
items: list[Widget] = [
tethering_btn,
tethering_password_btn,
text_item(lambda: tr("IP Address"), lambda: self._wifi_manager.ipv4_address),
self._roaming_btn,
self._apn_btn,
self._cellular_metered_btn,
wifi_metered_btn,
button_item(lambda: tr("Hidden Network"), lambda: tr("CONNECT"), callback=self._connect_to_hidden_network),
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
# Set initial config
metered = self._params.get_bool("GsmMetered")
self._wifi_manager.update_gsm_settings(roaming_enabled, self._params.get("GsmApn") or "", metered)
def _on_network_updated(self, networks: list[Network]):
self._tethering_action.set_enabled(True)
self._tethering_action.set_state(self._wifi_manager.is_tethering_active())
self._tethering_password_action.set_enabled(True)
if self._wifi_manager.is_tethering_active() or self._wifi_manager.ipv4_address == "":
self._wifi_metered_action.set_enabled(False)
self._wifi_metered_action.selected_button = 0
elif self._wifi_manager.ipv4_address != "":
metered = self._wifi_manager.current_network_metered
self._wifi_metered_action.set_enabled(True)
self._wifi_metered_action.selected_button = int(metered) if metered in (MeteredType.UNKNOWN, MeteredType.YES, MeteredType.NO) else 0
def _toggle_tethering(self):
checked = self._tethering_action.get_state()
self._tethering_action.set_enabled(False)
if checked:
self._wifi_metered_action.set_enabled(False)
self._wifi_manager.set_tethering_active(checked)
def _toggle_roaming(self):
roaming_state = self._roaming_action.get_state()
self._params.put_bool("GsmRoaming", roaming_state)
self._wifi_manager.update_gsm_settings(roaming_state, self._params.get("GsmApn") or "", self._params.get_bool("GsmMetered"))
def _edit_apn(self):
def update_apn(result):
if result != 1:
return
apn = self._keyboard.text.strip()
if apn == "":
self._params.remove("GsmApn")
else:
self._params.put("GsmApn", apn)
self._wifi_manager.update_gsm_settings(self._params.get_bool("GsmRoaming"), apn, self._params.get_bool("GsmMetered"))
current_apn = self._params.get("GsmApn") or ""
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration"))
self._keyboard.set_text(current_apn)
gui_app.set_modal_overlay(self._keyboard, update_apn)
def _toggle_cellular_metered(self):
metered = self._cellular_metered_action.get_state()
self._params.put_bool("GsmMetered", metered)
self._wifi_manager.update_gsm_settings(self._params.get_bool("GsmRoaming"), self._params.get("GsmApn") or "", metered)
def _toggle_wifi_metered(self, metered):
metered_type = {0: MeteredType.UNKNOWN, 1: MeteredType.YES, 2: MeteredType.NO}.get(metered, MeteredType.UNKNOWN)
self._wifi_metered_action.set_enabled(False)
self._wifi_manager.set_current_network_metered(metered_type)
def _connect_to_hidden_network(self):
def connect_hidden(result):
if result != 1:
return
ssid = self._keyboard.text
if not ssid:
return
def enter_password(result):
password = self._keyboard.text
if password == "":
# connect without password
self._wifi_manager.connect_to_network(ssid, "", hidden=True)
return
self._wifi_manager.connect_to_network(ssid, password, hidden=True)
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid))
gui_app.set_modal_overlay(self._keyboard, enter_password)
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Enter SSID"), "")
gui_app.set_modal_overlay(self._keyboard, connect_hidden)
def _edit_tethering_password(self):
def update_password(result):
if result != 1:
return
password = self._keyboard.text
self._wifi_manager.set_tethering_password(password)
self._tethering_password_action.set_enabled(False)
self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
self._keyboard.set_title(tr("Enter new tethering password"), "")
self._keyboard.set_text(self._wifi_manager.tethering_password)
gui_app.set_modal_overlay(self._keyboard, update_password)
def _update_state(self):
self._wifi_manager.process_callbacks()
# If not using prime SIM, show GSM settings and enable IPv4 forwarding
show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE)
self._wifi_manager.set_ipv4_forward(show_cell_settings)
self._roaming_btn.set_visible(show_cell_settings)
self._apn_btn.set_visible(show_cell_settings)
self._cellular_metered_btn.set_visible(show_cell_settings)
def _render(self, _):
self._scroller.render(self._rect)
class WifiManagerUI(Widget):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self.wifi_manager = wifi_manager
self._wifi_manager = wifi_manager
self.state: UIState = UIState.IDLE
self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING
self._password_retry: bool = False # for NEEDS_AUTH
@@ -49,40 +284,41 @@ class WifiManagerUI(Widget):
self._networks: list[Network] = []
self._networks_buttons: dict[str, Button] = {}
self._forget_networks_buttons: dict[str, Button] = {}
self._confirm_dialog = ConfirmDialog("", "Forget", "Cancel")
self.wifi_manager.set_callbacks(need_auth=self._on_need_auth,
activated=self._on_activated,
forgotten=self._on_forgotten,
networks_updated=self._on_network_updated,
disconnected=self._on_disconnected)
self._wifi_manager.add_callbacks(need_auth=self._on_need_auth,
activated=self._on_activated,
forgotten=self._on_forgotten,
networks_updated=self._on_network_updated,
disconnected=self._on_disconnected)
def show_event(self):
# start/stop scanning when widget is visible
self.wifi_manager.set_active(True)
self._wifi_manager.set_active(True)
def hide_event(self):
self.wifi_manager.set_active(False)
self._wifi_manager.set_active(False)
def _load_icons(self):
for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]:
gui_app.texture(icon, ICON_SIZE, ICON_SIZE)
def _render(self, rect: rl.Rectangle):
self.wifi_manager.process_callbacks()
def _update_state(self):
self._wifi_manager.process_callbacks()
def _render(self, rect: rl.Rectangle):
if not self._networks:
gui_label(rect, "Scanning Wi-Fi networks...", 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
return
if self.state == UIState.NEEDS_AUTH and self._state_network:
self.keyboard.set_title("Wrong password" if self._password_retry else "Enter password", f"for {self._state_network.ssid}")
self.keyboard.reset()
self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"), tr("for \"{}\"").format(self._state_network.ssid))
self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result))
elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network:
self._confirm_dialog.set_text(f'Forget Wi-Fi Network "{self._state_network.ssid}"?')
self._confirm_dialog.reset()
gui_app.set_modal_overlay(self._confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"))
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid))
confirm_dialog.reset()
gui_app.set_modal_overlay(confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
else:
self._draw_network_list(rect)
@@ -104,24 +340,23 @@ class WifiManagerUI(Widget):
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_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
offset = self.scroll_panel.update(rect, content_rect)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
for i, network in enumerate(self._networks):
y_offset = rect.y + i * ITEM_HEIGHT + offset.y
y_offset = rect.y + i * ITEM_HEIGHT + offset
item_rect = rl.Rectangle(rect.x, y_offset, rect.width, ITEM_HEIGHT)
if not rl.check_collision_recs(item_rect, rect):
continue
self._draw_network_item(item_rect, network, clicked)
self._draw_network_item(item_rect, network)
if i < len(self._networks) - 1:
line_y = int(item_rect.y + item_rect.height - 1)
rl.draw_line(int(item_rect.x), int(line_y), int(item_rect.x + item_rect.width), line_y, rl.LIGHTGRAY)
rl.end_scissor_mode()
def _draw_network_item(self, rect, network: Network, clicked: bool):
def _draw_network_item(self, rect, network: Network):
spacing = 50
ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - self.btn_width * 2, ITEM_HEIGHT)
signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
@@ -131,11 +366,11 @@ class WifiManagerUI(Widget):
if self.state == UIState.CONNECTING and self._state_network:
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = "CONNECTING..."
status_text = tr("CONNECTING...")
elif self.state == UIState.FORGETTING and self._state_network:
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = "FORGETTING..."
status_text = tr("FORGETTING...")
elif network.security_type == SecurityType.UNSUPPORTED:
self._networks_buttons[network.ssid].set_enabled(False)
else:
@@ -161,18 +396,16 @@ class WifiManagerUI(Widget):
self._draw_signal_strength_icon(signal_icon_rect, network)
def _networks_buttons_callback(self, network):
if self.scroll_panel.is_touch_valid():
if not network.is_saved and network.security_type != SecurityType.OPEN:
self.state = UIState.NEEDS_AUTH
self._state_network = network
self._password_retry = False
elif not network.is_connected:
self.connect_to_network(network)
if not network.is_saved and network.security_type != SecurityType.OPEN:
self.state = UIState.NEEDS_AUTH
self._state_network = network
self._password_retry = False
elif not network.is_connected:
self.connect_to_network(network)
def _forget_networks_buttons_callback(self, network):
if self.scroll_panel.is_touch_valid():
self.state = UIState.SHOW_FORGET_CONFIRM
self._state_network = network
self.state = UIState.SHOW_FORGET_CONFIRM
self._state_network = network
def _draw_status_icon(self, rect, network: Network):
"""Draw the status icon based on network's connection state"""
@@ -200,22 +433,24 @@ class WifiManagerUI(Widget):
self.state = UIState.CONNECTING
self._state_network = network
if network.is_saved and not password:
self.wifi_manager.activate_connection(network.ssid)
self._wifi_manager.activate_connection(network.ssid)
else:
self.wifi_manager.connect_to_network(network.ssid, password)
self._wifi_manager.connect_to_network(network.ssid, password)
def forget_network(self, network: Network):
self.state = UIState.FORGETTING
self._state_network = network
self.wifi_manager.forget_connection(network.ssid)
self._wifi_manager.forget_connection(network.ssid)
def _on_network_updated(self, networks: list[Network]):
self._networks = networks
for n in self._networks:
self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55, text_alignment=TextAlignment.LEFT,
button_style=ButtonStyle.NO_EFFECT)
self._forget_networks_buttons[n.ssid] = Button("Forget", partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI,
self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI,
font_size=45)
self._forget_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
def _on_need_auth(self, ssid):
network = next((n for n in self._networks if n.ssid == ssid), None)
+35 -28
View File
@@ -1,9 +1,10 @@
import pyray as rl
from openpilot.system.ui.lib.application import FontWeight
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import gui_button, ButtonStyle, TextAlignment
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.scroller import Scroller
# Constants
MARGIN = 50
@@ -16,13 +17,29 @@ LIST_ITEM_SPACING = 25
class MultiOptionDialog(Widget):
def __init__(self, title, options, current=""):
def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM):
super().__init__()
self.title = title
self.options = options
self.current = current
self.selection = current
self.scroll = GuiScrollPanel()
self._result: DialogResult = DialogResult.NO_ACTION
# Create scroller with option buttons
self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt),
font_weight=option_font_weight,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL,
text_padding=50, elide_right=True) for option in options]
self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING)
self.cancel_button = Button(lambda: tr("Cancel"), click_callback=lambda: self._set_result(DialogResult.CANCEL))
self.select_button = Button(lambda: tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY)
def _set_result(self, result: DialogResult):
self._result = result
def _on_option_clicked(self, option):
self.selection = option
def _render(self, rect):
dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, rect.height - 2 * MARGIN)
@@ -36,36 +53,26 @@ class MultiOptionDialog(Widget):
# 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)
options_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h)
# 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(options_y), int(view_rect.width), int(options_h))
# Update button styles and set width based on selection
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)
selected = option == self.selection
button = self.option_buttons[i]
button.set_button_style(ButtonStyle.PRIMARY if selected else ButtonStyle.NORMAL)
button.set_rect(rl.Rectangle(0, 0, options_rect.width, ITEM_HEIGHT))
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()
self.scroller.render(options_rect)
# Buttons
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
button_w = (content_rect.width - BUTTON_SPACING) / 2
if gui_button(rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT), "Cancel"):
return 0
cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT)
self.cancel_button.render(cancel_rect)
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
select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT)
self.select_button.set_enabled(self.selection != self.current)
self.select_button.render(select_rect)
return -1
return self._result
+23 -7
View File
@@ -18,7 +18,7 @@ class LineSeparator(Widget):
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),
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
LINE_COLOR)
@@ -27,7 +27,7 @@ class Scroller(Widget):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
self._line_separator = line_separator
self._line_separator = LineSeparator() if line_separator else None
self._pad_end = pad_end
self.scroll_panel = GuiScrollPanel()
@@ -36,18 +36,23 @@ class Scroller(Widget):
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]
# Add line separator between items
if self._line_separator is not None:
l = len(visible_items)
for i in range(1, len(visible_items)):
visible_items.insert(l - i, self._line_separator)
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))
scroll = self.scroll_panel.update(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))
@@ -63,8 +68,7 @@ class Scroller(Widget):
cur_height += item.rect.height + self._spacing * (idx != 0)
# Consider scroll
x += scroll.x
y += scroll.y
y += scroll
# Update item state
item.set_position(x, y)
@@ -72,3 +76,15 @@ class Scroller(Widget):
item.render()
rl.end_scissor_mode()
def show_event(self):
super().show_event()
# Reset to top
self.scroll_panel.set_offset(0)
for item in self._items:
item.show_event()
def hide_event(self):
super().hide_event()
for item in self._items:
item.hide_event()
+13 -2
View File
@@ -1,4 +1,5 @@
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import MousePos
from openpilot.system.ui.widgets import Widget
@@ -14,12 +15,14 @@ ANIMATION_SPEED = 8.0
class Toggle(Widget):
def __init__(self, initial_state=False):
def __init__(self, initial_state: bool = False, callback: Callable[[bool], None] | None = None):
super().__init__()
self._state = initial_state
self._callback = callback
self._enabled = True
self._progress = 1.0 if initial_state else 0.0
self._target = self._progress
self._clicked = False
def set_rect(self, rect: rl.Rectangle):
self._rect = rl.Rectangle(rect.x, rect.y, WIDTH, HEIGHT)
@@ -28,10 +31,13 @@ class Toggle(Widget):
if not self._enabled:
return
self._clicked = True
self._state = not self._state
self._target = 1.0 if self._state else 0.0
if self._callback:
self._callback(self._state)
def get_state(self):
def get_state(self) -> bool:
return self._state
def set_state(self, state: bool):
@@ -66,5 +72,10 @@ class Toggle(Widget):
knob_y = self._rect.y + HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
# TODO: use click callback
clicked = self._clicked
self._clicked = False
return clicked
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)