dragonpilot v0.10.3

This commit is contained in:
Comma Device
2026-02-27 02:35:32 +00:00
commit e3c05cbf5d
4131 changed files with 1431819 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# ui
The user interfaces here are built with [raylib](https://www.raylib.com/).
Quick start:
* set `BIG=1` to run the comma 3X UI (comma four UI runs by default)
* set `SHOW_FPS=1` to show the FPS
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
* set `SCALE=1.5` to scale the entire UI by 1.5x
* set `BURN_IN=1` to get a burn-in heatmap version of the UI
* set `GRID=50` to show a 50-pixel alignment grid overlay
* set `MAGIC_DEBUG=1` to show every dropped frames (only on device)
* set `RECORD=1` to record the screen, output defaults to `output.mp4` but can be set with `RECORD_OUTPUT`
* https://www.raylib.com/cheatsheet/cheatsheet.html
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
Style guide:
* All graphical elements should subclass [`Widget`](/system/ui/widgets/__init__.py).
* Prefer a stateful widget over a function for easy migration from QT
* All internal class variables and functions should be prefixed with `_`
View File
+807
View File
@@ -0,0 +1,807 @@
import atexit
import cffi
import os
import time
import signal
import sys
import pyray as rl
import threading
import platform
import subprocess
from contextlib import contextmanager
from collections.abc import Callable
from collections import deque
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import NamedTuple
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
try:
from openpilot.common.params import Params
except ImportError:
Params = None
_DEFAULT_FPS = int(os.getenv("FPS", {'tici': 20, '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
BIG_UI = os.getenv("BIG", "0") == "1"
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"))
GRID_SIZE = int(os.getenv("GRID", "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
RECORD = os.getenv("RECORD") == "1"
RECORD_OUTPUT = str(Path(os.getenv("RECORD_OUTPUT", "output")).with_suffix(".mp4"))
GL_VERSION = """
#version 300 es
precision highp float;
"""
if platform.system() == "Darwin":
GL_VERSION = """
#version 330 core
"""
BURN_IN_MODE = "BURN_IN" in os.environ
BURN_IN_VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
out vec2 fragTexCoord;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
out vec4 fragColor;
void main() {
vec4 sampled = texture(texture0, fragTexCoord);
float intensity = sampled.b;
// Map blue intensity to green -> yellow -> red to highlight burn-in risk.
vec3 start = vec3(0.0, 1.0, 0.0);
vec3 middle = vec3(1.0, 1.0, 0.0);
vec3 end = vec3(1.0, 0.0, 0.0);
vec3 gradient = mix(start, middle, clamp(intensity * 2.0, 0.0, 1.0));
gradient = mix(gradient, end, clamp((intensity - 0.5) * 2.0, 0.0, 1.0));
fragColor = vec4(gradient, sampled.a);
}
"""
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
# 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 if BIG_UI else 1.16
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
LIGHT = "Inter-Light.fnt"
NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
BOLD = "Inter-Bold.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "OpFont-Regular-Labels.fnt"
# Small UI fonts
DISPLAY_REGULAR = "Inter-Regular.fnt"
ROMAN = "Inter-Regular.fnt"
DISPLAY = "Inter-Bold.fnt"
_OPFONT_WEIGHT = {
"Inter-Light.fnt": "Regular",
"Inter-Regular.fnt": "Regular",
"Inter-Medium.fnt": "Medium",
"Inter-SemiBold.fnt": "SemiBold",
"Inter-Bold.fnt": "Bold",
}
def _opfont_filename(inter_filename: str, lang_code: str) -> str:
"""Map an Inter font filename to the equivalent OpFont filename for a language."""
weight_name = _OPFONT_WEIGHT.get(inter_filename, "Regular")
return f"OpFont-{weight_name}-{lang_code}.fnt"
def font_fallback(font: rl.Font) -> rl.Font:
"""Ensure the font is from the current language's font set.
Widgets may cache rl.Font references. After a language switch, those references
are stale (freed GPU texture). This catches them and returns the current equivalent.
"""
if not gui_app._font_remap:
return font # no language switch has occurred
# Check if this is a currently loaded font (handles texture ID reuse)
for f in gui_app._fonts.values():
if font.texture.id == f.texture.id:
return f
# Stale reference — look up the original weight and return current font for it
weight = gui_app._font_remap.get(font.texture.id)
if weight:
return gui_app.font(weight)
return gui_app.font(FontWeight.NORMAL)
@dataclass
class ModalOverlay:
overlay: object = None
callback: Callable | None = None
class MousePos(NamedTuple):
x: float
y: float
class MousePosWithTime(NamedTuple):
x: float
y: float
t: float
class MouseEvent(NamedTuple):
pos: MousePos
slot: int
left_pressed: bool
left_released: bool
left_down: bool
t: float
class MouseState:
def __init__(self, scale: float = 1.0):
self._scale = scale
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, print_delay_threshold=None)
self._lock = threading.Lock()
self._exit_event = threading.Event()
self._thread = None
def get_events(self) -> list[MouseEvent]:
with self._lock:
events = list(self._events)
self._events.clear()
return events
def start(self):
self._exit_event.clear()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._run_thread, daemon=True)
self._thread.start()
def stop(self):
self._exit_event.set()
if self._thread is not None and self._thread.is_alive():
self._thread.join()
def _run_thread(self):
while not self._exit_event.is_set():
rl.poll_input_events()
self._handle_mouse_event()
self._rk.keep_time()
def _handle_mouse_event(self):
for slot in range(MAX_TOUCH_SLOTS):
mouse_pos = rl.get_touch_position(slot)
x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x
y = mouse_pos.y / self._scale if self._scale != 1.0 else mouse_pos.y
ev = MouseEvent(
MousePos(x, y),
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(),
)
# Only add changes
if self._prev_mouse_event[slot] is None or ev[:-1] != self._prev_mouse_event[slot][:-1]:
with self._lock:
self._events.append(ev)
self._prev_mouse_event[slot] = ev
class GuiApplication:
def __init__(self, width: int | None = None, height: int | None = None):
self._fonts: dict[FontWeight, rl.Font] = {}
if Params is not None:
dp_ui_mici = Params().get_bool("dp_ui_mici")
else:
dp_ui_mici = False
self._width = width if width is not None else GuiApplication._default_width(dp_ui_mici)
self._height = height if height is not None else GuiApplication._default_height(dp_ui_mici)
self._active_lang_code: str = ""
self._font_remap: dict[int, FontWeight] = {} # old texture ID → weight (for stale references)
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
else:
self._scale = 4.0 if dp_ui_mici else SCALE
# Scale, then ensure dimensions are even
self._scaled_width = int(self._width * self._scale)
self._scaled_height = int(self._height * self._scale)
self._scaled_width += self._scaled_width % 2
self._scaled_height += self._scaled_height % 2
self._render_texture: rl.RenderTexture | None = None
self._burn_in_shader: rl.Shader | None = None
self._ffmpeg_proc: subprocess.Popen | None = None
self._textures: dict[str, rl.Texture] = {}
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._modal_overlay_tick: Callable[[], None] | None = None
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[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE)
self._show_touches = SHOW_TOUCHES
self._show_fps = SHOW_FPS
self._grid_size = GRID_SIZE
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):
with self._startup_profile_context():
def _close(sig, frame):
self.close()
sys.exit(0)
signal.signal(signal.SIGINT, _close)
atexit.register(self.close)
self._set_log_callback()
rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING)
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)
needs_render_texture = self._scale != 1.0 or BURN_IN_MODE or RECORD
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
if needs_render_texture:
self._render_texture = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
if RECORD:
ffmpeg_args = [
'ffmpeg',
'-v', 'warning', # Reduce ffmpeg log spam
'-stats', # Show encoding progress
'-f', 'rawvideo', # Input format
'-pix_fmt', 'rgba', # Input pixel format
'-s', f'{self._width}x{self._height}', # Input resolution
'-r', str(fps), # Input frame rate
'-i', 'pipe:0', # Input from stdin
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v', 'libx264', # Video codec
'-preset', 'ultrafast', # Encoding speed
'-y', # Overwrite existing file
'-f', 'mp4', # Output format
RECORD_OUTPUT, # Output file path
]
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
rl.set_target_fps(fps)
self._target_fps = fps
self._set_styles()
self._load_fonts()
self._patch_text_functions()
if BURN_IN_MODE and self._burn_in_shader is None:
self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER)
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 hasattr(self._modal_overlay.overlay, 'hide_event'):
self._modal_overlay.overlay.hide_event()
if self._modal_overlay.callback is not None:
self._modal_overlay.callback(-1)
self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback)
def set_modal_overlay_tick(self, tick_function: Callable | None):
self._modal_overlay_tick = tick_function
def set_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:
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_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)
if width is not None and height is not None:
same_dimensions = image.width == width and image.height == height
# Resize with aspect ratio preservation if requested
if not same_dimensions:
if keep_aspect_ratio:
orig_width = image.width
orig_height = image.height
scale_width = width / orig_width
scale_height = height / orig_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:
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
def close_ffmpeg(self):
if self._ffmpeg_proc is not None:
self._ffmpeg_proc.stdin.flush()
self._ffmpeg_proc.stdin.close()
try:
self._ffmpeg_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._ffmpeg_proc.terminate()
self._ffmpeg_proc.wait()
def close(self):
if not rl.is_window_ready():
return
for texture in self._textures.values():
rl.unload_texture(texture)
self._textures = {}
for font in self._fonts.values():
rl.unload_font(font)
self._fonts = {}
self._active_lang_code = ""
if self._render_texture is not None:
rl.unload_render_texture(self._render_texture)
self._render_texture = None
if self._burn_in_shader:
rl.unload_shader(self._burn_in_shader)
self._burn_in_shader = None
if not PC:
self._mouse.stop()
self.close_ffmpeg()
rl.close_window()
@property
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
self._mouse._handle_mouse_event()
# 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)
rl.clear_background(rl.BLACK)
else:
rl.begin_drawing()
rl.clear_background(rl.BLACK)
# Handle modal overlay rendering and input processing
if self._handle_modal_overlay():
# Allow a Widget to still run a function while overlay is shown
if self._modal_overlay_tick is not None:
self._modal_overlay_tick()
yield False
else:
yield True
if self._render_texture:
rl.end_texture_mode()
rl.begin_drawing()
rl.clear_background(rl.BLACK)
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
texture = self._render_texture.texture
if texture:
if BURN_IN_MODE and self._burn_in_shader:
rl.begin_shader_mode(self._burn_in_shader)
rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
else:
rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
if self._show_fps:
rl.draw_fps(10, 10)
if self._show_touches:
self._draw_touch_points()
if self._grid_size > 0:
self._draw_grid()
rl.end_drawing()
if RECORD:
image = rl.load_image_from_texture(self._render_texture.texture)
data_size = image.width * image.height * 4
data = bytes(rl.ffi.buffer(image.data, data_size))
self._ffmpeg_proc.stdin.write(data)
self._ffmpeg_proc.stdin.flush()
rl.unload_image(image)
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) -> rl.Font:
if font_weight not in self._fonts:
# For languages need unifont, load OpFont instead of Inter (except labels font)
if multilang.requires_unifont() and font_weight != FontWeight.UNIFONT:
filename = _opfont_filename(font_weight.value, self._active_lang_code)
else:
filename = font_weight.value
with as_file(FONT_DIR) as fspath:
fnt_path = fspath / filename
# Fall back to Regular weight if requested weight doesn't exist
if not fnt_path.exists() and multilang.requires_unifont():
filename = f"OpFont-Regular-{self._active_lang_code}.fnt"
fnt_path = fspath / filename
font = rl.load_font(fnt_path.as_posix())
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._fonts[font_weight] = font
return self._fonts[font_weight]
@property
def width(self):
return self._width
@property
def height(self):
return self._height
def _handle_modal_overlay(self) -> bool:
if self._modal_overlay.overlay:
if hasattr(self._modal_overlay.overlay, 'render'):
result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height))
elif callable(self._modal_overlay.overlay):
result = self._modal_overlay.overlay()
else:
raise Exception
# Send show event to Widget
if not self._modal_overlay_shown and hasattr(self._modal_overlay.overlay, 'show_event'):
self._modal_overlay.overlay.show_event()
self._modal_overlay_shown = True
if result >= 0:
# Clear the overlay and execute the callback
original_modal = self._modal_overlay
self._modal_overlay = ModalOverlay()
if hasattr(original_modal.overlay, 'hide_event'):
original_modal.overlay.hide_event()
if original_modal.callback is not None:
original_modal.callback(result)
return True
else:
self._modal_overlay_shown = False
return False
def _load_fonts(self):
self._active_lang_code = multilang.language
rl.gui_set_font(self.font(FontWeight.NORMAL))
def on_language_changed(self, lang_code: str):
# Map old texture IDs → weights so we can remap stale references
old_weight_by_texture = {f.texture.id: w for w, f in self._fonts.items()}
old_fonts = list(self._fonts.values())
self._fonts = {}
self._active_lang_code = lang_code
# Load new fonts for all weights that were active
for weight in old_weight_by_texture.values():
self.font(weight)
# Carry forward existing remap + add new entries (weights are stable across switches)
self._font_remap = dict(self._font_remap) | {tid: w for tid, w in old_weight_by_texture.items()}
# Now safe to unload old fonts
for f in old_fonts:
rl.unload_font(f)
rl.gui_set_font(self.font(FontWeight.NORMAL))
from openpilot.system.ui.lib import text_measure, wrap_text
text_measure._cache.clear()
wrap_text._cache.clear()
def _set_styles(self):
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0)
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE)
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK))
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("""
int vasprintf(char **strp, const char *fmt, void *ap);
void free(void *ptr);
""")
libc = ffi_libc.dlopen(None)
@rl.ffi.callback("void(int, char *, void *)")
def trace_log_callback(log_level, text, args):
try:
text_addr = int(rl.ffi.cast("uintptr_t", text))
args_addr = int(rl.ffi.cast("uintptr_t", args))
text_libc = ffi_libc.cast("char *", text_addr)
args_libc = ffi_libc.cast("void *", args_addr)
out = ffi_libc.new("char **")
if libc.vasprintf(out, text_libc, args_libc) >= 0 and out[0] != ffi_libc.NULL:
text_str = ffi_libc.string(out[0]).decode("utf-8", "replace")
libc.free(out[0])
else:
text_str = rl.ffi.string(text).decode("utf-8", "replace")
except Exception as e:
text_str = f"[Log decode error: {e}]"
if log_level == rl.TraceLogLevel.LOG_ERROR:
cloudlog.error(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_WARNING:
cloudlog.warning(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_INFO:
cloudlog.info(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_DEBUG:
cloudlog.debug(f"raylib: {text_str}")
else:
cloudlog.error(f"raylib: Unknown level {log_level}: {text_str}")
# Store callback reference
self._trace_log_callback = trace_log_callback
rl.set_trace_log_callback(self._trace_log_callback)
def _monitor_fps(self):
fps = rl.get_fps()
# Log FPS drop below threshold at regular intervals
if fps < self._target_fps * FPS_DROP_THRESHOLD:
current_time = time.monotonic()
if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL:
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}")
self._last_fps_log_time = current_time
# Strict mode: terminate UI if FPS drops too much
if STRICT_MODE and fps < self._target_fps * FPS_CRITICAL_THRESHOLD:
cloudlog.error(f"FPS dropped critically below {fps}. Shutting down UI.")
self.close_ffmpeg()
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 _draw_grid(self):
grid_color = rl.Color(60, 60, 60, 255)
# Draw vertical lines
x = 0
while x <= self._scaled_width:
rl.draw_line(x, 0, x, self._scaled_height, grid_color)
x += self._grid_size
# Draw horizontal lines
y = 0
while y <= self._scaled_height:
rl.draw_line(0, y, self._scaled_width, y, grid_color)
y += self._grid_size
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)
@staticmethod
def _default_width(dp_ui_four: bool = False) -> int:
return 536 if dp_ui_four else 2160 if GuiApplication.big_ui() else 536
@staticmethod
def _default_height(dp_ui_four: bool = False) -> int:
return 240 if dp_ui_four else 1080 if GuiApplication.big_ui() else 240
@staticmethod
def big_ui() -> bool:
return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI
gui_app = GuiApplication()
+181
View File
@@ -0,0 +1,181 @@
import os
import cffi
from dataclasses import dataclass
from typing import Any
from openpilot.common.swaglog import cloudlog
# EGL constants
EGL_LINUX_DMA_BUF_EXT = 0x3270
EGL_WIDTH = 0x3057
EGL_HEIGHT = 0x3056
EGL_LINUX_DRM_FOURCC_EXT = 0x3271
EGL_DMA_BUF_PLANE0_FD_EXT = 0x3272
EGL_DMA_BUF_PLANE0_OFFSET_EXT = 0x3273
EGL_DMA_BUF_PLANE0_PITCH_EXT = 0x3274
EGL_DMA_BUF_PLANE1_FD_EXT = 0x3275
EGL_DMA_BUF_PLANE1_OFFSET_EXT = 0x3276
EGL_DMA_BUF_PLANE1_PITCH_EXT = 0x3277
EGL_NONE = 0x3038
GL_TEXTURE0 = 0x84C0
GL_TEXTURE_EXTERNAL_OES = 0x8D65
# DRM Format for NV12
DRM_FORMAT_NV12 = 842094158
@dataclass
class EGLImage:
"""Container for EGL image and associated resources"""
egl_image: Any
fd: int
@dataclass
class EGLState:
"""Container for all EGL-related state"""
initialized: bool = False
ffi: Any = None
egl_lib: Any = None
gles_lib: Any = None
# EGL display connection - shared across all users
display: Any = None
# Constants
NO_CONTEXT: Any = None
NO_DISPLAY: Any = None
NO_IMAGE_KHR: Any = None
# Function pointers
get_current_display: Any = None
create_image_khr: Any = None
destroy_image_khr: Any = None
image_target_texture: Any = None
get_error: Any = None
bind_texture: Any = None
active_texture: Any = None
# Create a single instance of the state
_egl = EGLState()
def init_egl() -> bool:
"""Initialize EGL and load necessary functions"""
global _egl
# Don't re-initialize if already done
if _egl.initialized:
return True
try:
_egl.ffi = cffi.FFI()
_egl.ffi.cdef("""
typedef int EGLint;
typedef unsigned int EGLBoolean;
typedef unsigned int EGLenum;
typedef unsigned int GLenum;
typedef void *EGLContext;
typedef void *EGLDisplay;
typedef void *EGLClientBuffer;
typedef void *EGLImageKHR;
typedef void *GLeglImageOES;
EGLDisplay eglGetCurrentDisplay(void);
EGLint eglGetError(void);
EGLImageKHR eglCreateImageKHR(EGLDisplay dpy, EGLContext ctx,
EGLenum target, EGLClientBuffer buffer,
const EGLint *attrib_list);
EGLBoolean eglDestroyImageKHR(EGLDisplay dpy, EGLImageKHR image);
void glEGLImageTargetTexture2DOES(GLenum target, GLeglImageOES image);
void glBindTexture(GLenum target, unsigned int texture);
void glActiveTexture(GLenum texture);
""")
# Load libraries
_egl.egl_lib = _egl.ffi.dlopen("libEGL.so")
_egl.gles_lib = _egl.ffi.dlopen("libGLESv2.so")
# Cast NULL pointers
_egl.NO_CONTEXT = _egl.ffi.cast("void *", 0)
_egl.NO_DISPLAY = _egl.ffi.cast("void *", 0)
_egl.NO_IMAGE_KHR = _egl.ffi.cast("void *", 0)
# Bind functions
_egl.get_current_display = _egl.egl_lib.eglGetCurrentDisplay
_egl.create_image_khr = _egl.egl_lib.eglCreateImageKHR
_egl.destroy_image_khr = _egl.egl_lib.eglDestroyImageKHR
_egl.image_target_texture = _egl.gles_lib.glEGLImageTargetTexture2DOES
_egl.get_error = _egl.egl_lib.eglGetError
_egl.bind_texture = _egl.gles_lib.glBindTexture
_egl.active_texture = _egl.gles_lib.glActiveTexture
# Initialize EGL display once here
_egl.display = _egl.get_current_display()
if _egl.display == _egl.NO_DISPLAY:
raise RuntimeError("Failed to get EGL display")
_egl.initialized = True
return True
except Exception as e:
cloudlog.exception(f"EGL initialization failed: {e}")
_egl.initialized = False
return False
def create_egl_image(width: int, height: int, stride: int, fd: int, uv_offset: int) -> EGLImage | None:
assert _egl.initialized, "EGL not initialized"
try:
# Duplicate fd since EGL needs it
dup_fd = os.dup(fd)
except OSError as e:
cloudlog.exception(f"Failed to duplicate frame fd when creating EGL image: {e}")
return None
# Create image attributes for EGL
img_attrs = [
EGL_WIDTH, width,
EGL_HEIGHT, height,
EGL_LINUX_DRM_FOURCC_EXT, DRM_FORMAT_NV12,
EGL_DMA_BUF_PLANE0_FD_EXT, dup_fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0,
EGL_DMA_BUF_PLANE0_PITCH_EXT, stride,
EGL_DMA_BUF_PLANE1_FD_EXT, dup_fd,
EGL_DMA_BUF_PLANE1_OFFSET_EXT, uv_offset,
EGL_DMA_BUF_PLANE1_PITCH_EXT, stride,
EGL_NONE
]
attr_array = _egl.ffi.new("int[]", img_attrs)
egl_image = _egl.create_image_khr(_egl.display, _egl.NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, _egl.ffi.NULL, attr_array)
if egl_image == _egl.NO_IMAGE_KHR:
cloudlog.error(f"Failed to create EGL image: {_egl.get_error()}")
os.close(dup_fd)
return None
return EGLImage(egl_image=egl_image, fd=dup_fd)
def destroy_egl_image(egl_image: EGLImage) -> None:
assert _egl.initialized, "EGL not initialized"
_egl.destroy_image_khr(_egl.display, egl_image.egl_image)
# Close the duplicated fd we created in create_egl_image()
# We need to handle OSError since the fd might already be closed
try:
os.close(egl_image.fd)
except OSError:
pass
def bind_egl_image_to_texture(texture_id: int, egl_image: EGLImage) -> None:
assert _egl.initialized, "EGL not initialized"
_egl.active_texture(GL_TEXTURE0)
_egl.bind_texture(GL_TEXTURE_EXTERNAL_OES, texture_id)
_egl.image_target_texture(GL_TEXTURE_EXTERNAL_OES, egl_image.egl_image)
+55
View File
@@ -0,0 +1,55 @@
import io
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(
"""[\U0001F600-\U0001F64F
\U0001F300-\U0001F5FF
\U0001F680-\U0001F6FF
\U0001F1E0-\U0001F1FF
\U00002700-\U000027BF
\U0001F900-\U0001F9FF
\U00002600-\U000026FF
\U00002300-\U000023FF
\U00002B00-\U00002BFF
\U0001FA70-\U0001FAFF
\U0001F700-\U0001F77F
\u2640-\u2642
\u2600-\u2B55
\u200d
\u23cf
\u23e9
\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)]
def emoji_tex(emoji):
if emoji not in _cache:
img = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
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
+46
View File
@@ -0,0 +1,46 @@
from enum import IntEnum
# NetworkManager device states
class NMDeviceState(IntEnum):
UNKNOWN = 0
DISCONNECTED = 30
PREPARE = 40
STATE_CONFIG = 50
NEED_AUTH = 60
IP_CONFIG = 70
ACTIVATED = 100
DEACTIVATING = 110
# NetworkManager constants
NM = "org.freedesktop.NetworkManager"
NM_PATH = '/org/freedesktop/NetworkManager'
NM_IFACE = 'org.freedesktop.NetworkManager'
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_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
NM_DEVICE_TYPE_WIFI = 2
NM_DEVICE_TYPE_MODEM = 8
NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags
NM_802_11_AP_FLAGS_NONE = 0x0
NM_802_11_AP_FLAGS_PRIVACY = 0x1
NM_802_11_AP_FLAGS_WPS = 0x2
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags
NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001
NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002
NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010
NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020
NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100
NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200
+134
View File
@@ -0,0 +1,134 @@
import math
import pyray as rl
from enum import IntEnum
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 = 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 # Not dragging, content may be bouncing or scrolling with inertia
DRAGGING_CONTENT = 1 # User is actively dragging the content
class GuiScrollPanel:
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_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
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)
self._update_state(bounds, content)
return float(self._offset_filter_y.x)
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
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED
max_scroll_distance = max(0, content.height - bounds.height)
if self._scroll_state == ScrollState.IDLE:
above_bounds, below_bounds = self._check_bounds(bounds, content)
# 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_filter_y.x = 0.0
if above_bounds or below_bounds:
if above_bounds:
self._offset_filter_y.update(0)
else:
self._offset_filter_y.update(-max_scroll_distance)
self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps
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
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 self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING
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
@property
def offset(self) -> float:
return float(self._offset_filter_y.x)
+225
View File
@@ -0,0 +1,225 @@
import os
import math
import pyray as rl
from collections.abc import Callable
from enum import Enum
from typing import cast
from openpilot.system.ui.lib.application import gui_app, MouseEvent
from openpilot.system.hardware import TICI
from collections import deque
MIN_VELOCITY = 10 # 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
MIN_DRAG_PIXELS = 12
AUTO_SCROLL_TC_SNAP = 0.025
AUTO_SCROLL_TC = 0.18
BOUNCE_RETURN_RATE = 10.0
REJECT_DECELERATION_FACTOR = 3
MAX_SPEED = 10000.0 # px/s
DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
class ScrollState(Enum):
STEADY = 0
PRESSED = 1
MANUAL_SCROLL = 2
AUTO_SCROLL = 3
class GuiScrollPanel2:
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
self._horizontal = horizontal
self._handle_out_of_bounds = handle_out_of_bounds
self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC
self._state = ScrollState.STEADY
self._offset: rl.Vector2 = rl.Vector2(0, 0)
self._initial_click_event: MouseEvent | None = None
self._previous_mouse_event: MouseEvent | None = None
self._velocity = 0.0 # pixels per second
self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6)
self._enabled: bool | Callable[[], bool] = True
def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._enabled = enabled
@property
def enabled(self) -> bool:
return self._enabled() if callable(self._enabled) else self._enabled
def update(self, bounds: rl.Rectangle, content_size: float) -> float:
if DEBUG:
print('Old state:', self._state)
bounds_size = bounds.width if self._horizontal else bounds.height
for mouse_event in gui_app.mouse_events:
self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
self._previous_mouse_event = mouse_event
self._update_state(bounds_size, content_size)
if DEBUG:
print('Velocity:', self._velocity)
print('Offset X:', self._offset.x, 'Y:', self._offset.y)
print('New state:', self._state)
print()
return self.get_offset()
def _get_offset_bounds(self, bounds_size: float, content_size: float) -> tuple[float, float]:
"""Returns (max_offset, min_offset) for the given bounds and content size."""
return 0.0, min(0.0, bounds_size - content_size)
def _update_state(self, bounds_size: float, content_size: float) -> None:
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
if self._state == ScrollState.AUTO_SCROLL:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
# simple exponential return if out of bounds
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if out_of_bounds and self._handle_out_of_bounds:
target = max_offset if self.get_offset() > max_offset else min_offset
dt = rl.get_frame_time() or 1e-6
factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt)
dist = target - self.get_offset()
self.set_offset(self.get_offset() + dist * factor) # ease toward the edge
self._velocity *= (1.0 - factor) # damp any leftover fling
# Steady once we are close enough to the target
if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY:
self.set_offset(target)
self._velocity = 0.0
self._state = ScrollState.STEADY
elif abs(self._velocity) < MIN_VELOCITY:
self._velocity = 0.0
self._state = ScrollState.STEADY
# Update the offset based on the current velocity
dt = rl.get_frame_time()
self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity
alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt))
self._velocity *= alpha
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
# simple exponential return if out of bounds
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if DEBUG:
print('Mouse event:', mouse_event)
mouse_pos = self._get_mouse_pos(mouse_event)
if not self.enabled:
# Reset state if not enabled
self._state = ScrollState.STEADY
self._velocity = 0.0
self._velocity_buffer.clear()
elif self._state == ScrollState.STEADY:
if rl.check_collision_point_rec(mouse_event.pos, bounds):
if mouse_event.left_pressed:
self._state = ScrollState.PRESSED
self._initial_click_event = mouse_event
elif self._state == ScrollState.PRESSED:
initial_click_pos = self._get_mouse_pos(cast(MouseEvent, self._initial_click_event))
diff = abs(mouse_pos - initial_click_pos)
if mouse_event.left_released:
# Special handling for down and up clicks across two frames
# TODO: not sure what that means or if it's accurate anymore
if out_of_bounds:
self._state = ScrollState.AUTO_SCROLL
elif diff <= MIN_DRAG_PIXELS:
self._state = ScrollState.STEADY
else:
self._state = ScrollState.MANUAL_SCROLL
elif diff > MIN_DRAG_PIXELS:
self._state = ScrollState.MANUAL_SCROLL
elif self._state == ScrollState.MANUAL_SCROLL:
if mouse_event.left_released:
# Touch rejection: when releasing finger after swiping and stopping, panel
# reports a few erroneous touch events with high velocity, try to ignore.
# If velocity decelerates very quickly, assume user doesn't intend to auto scroll
high_decel = False
if len(self._velocity_buffer) > 2:
# We limit max to first half since final few velocities can surpass first few
abs_velocity_buffer = [(abs(v), i) for i, v in enumerate(self._velocity_buffer)]
max_idx = max(abs_velocity_buffer[:len(abs_velocity_buffer) // 2])[1]
min_idx = min(abs_velocity_buffer)[1]
if DEBUG:
print('min_idx:', min_idx, 'max_idx:', max_idx, 'velocity buffer:', self._velocity_buffer)
if (abs(self._velocity_buffer[min_idx]) * REJECT_DECELERATION_FACTOR < abs(self._velocity_buffer[max_idx]) and
max_idx < min_idx):
if DEBUG:
print('deceleration too high, going to STEADY')
high_decel = True
# If final velocity is below some threshold, switch to steady state too
low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin
if out_of_bounds or not (high_decel or low_speed):
self._state = ScrollState.AUTO_SCROLL
else:
# TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares
self._velocity = 0.0
self._state = ScrollState.STEADY
self._velocity_buffer.clear()
else:
# Update velocity for when we release the mouse button.
# Do not update velocity on the same frame the mouse was released
previous_mouse_pos = self._get_mouse_pos(cast(MouseEvent, self._previous_mouse_event))
delta_x = mouse_pos - previous_mouse_pos
delta_t = max((mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t), 1e-6)
self._velocity = delta_x / delta_t
self._velocity = max(-MAX_SPEED, min(MAX_SPEED, self._velocity))
self._velocity_buffer.append(self._velocity)
# rubber-banding: reduce dragging when out of bounds
# TODO: this drifts when dragging quickly
if out_of_bounds:
delta_x *= 0.25
# Update the offset based on the mouse movement
# Use internal _offset directly to preserve precision (don't round via get_offset())
# TODO: make get_offset return float
current_offset = self._offset.x if self._horizontal else self._offset.y
self.set_offset(current_offset + delta_x)
elif self._state == ScrollState.AUTO_SCROLL:
if mouse_event.left_pressed:
# Decide whether to click or scroll (block click if moving too fast)
if abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING:
# Traveling slow enough, click
self._state = ScrollState.PRESSED
self._initial_click_event = mouse_event
else:
# Go straight into manual scrolling to block erroneous input
self._state = ScrollState.MANUAL_SCROLL
# Reset velocity for touch down and up events that happen in back-to-back frames
self._velocity = 0.0
def _get_mouse_pos(self, mouse_event: MouseEvent) -> float:
return mouse_event.pos.x if self._horizontal else mouse_event.pos.y
def get_offset(self) -> float:
return self._offset.x if self._horizontal else self._offset.y
def set_offset(self, value: float) -> None:
if self._horizontal:
self._offset.x = value
else:
self._offset.y = value
@property
def state(self) -> ScrollState:
return self._state
def is_touch_valid(self) -> bool:
# MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state
return bool(self._state != ScrollState.MANUAL_SCROLL)
+238
View File
@@ -0,0 +1,238 @@
import pyray as rl
import numpy as np
from dataclasses import dataclass
from typing import Any, Optional, cast
from openpilot.system.ui.lib.application import gui_app, GL_VERSION
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)]
FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
uniform vec4 fillColor;
// Gradient line defined in *screen pixels*
uniform int useGradient;
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 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);
// 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];
for (int i = 0; i < gradientColorCount - 1; i++) {
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];
}
void main() {
// TODO: do proper antialiasing
finalColor = useGradient == 1 ? getGradientColor(gl_FragCoord.xy) : fillColor;
}
"""
# Default vertex shader
VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
uniform mat4 mvp;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
UNIFORM_VEC4 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4
class ShaderState:
_instance: Any = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
if ShaderState._instance is not None:
raise Exception("This class is a singleton. Use get_instance() instead.")
self.initialized = False
self.shader = None
# Shader uniform locations
self.locations = {
'fillColor': None,
'useGradient': None,
'gradientStart': None,
'gradientEnd': None,
'gradientColors': None,
'gradientStops': None,
'gradientColorCount': None,
'mvp': None,
}
# Pre-allocated FFI objects
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.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)
def initialize(self):
if self.initialized:
return
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
# Cache all uniform locations
for uniform in self.locations.keys():
self.locations[uniform] = rl.get_shader_location(self.shader, uniform)
# 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.shader:
rl.unload_shader(self.shader)
self.shader = None
self.initialized = False
def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
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:
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))
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 triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
"""Only supports simple polygons with two chains (ribbon)."""
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
# assert len(pts) % 2 == 0, "Interleaving expects even number of points"
if len(pts) % 2 != 0:
pts = pts[:-1]
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):
"""
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()
state.initialize()
# 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)"
# Configure gradient shader
_configure_shader_color(state, color, gradient, origin_rect)
# Triangulate via interleaving
tri_strip = triangulate(pts)
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
def cleanup_shader_resources():
state = ShaderState.get_instance()
state.cleanup()
+36
View File
@@ -0,0 +1,36 @@
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: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
# 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
+18
View File
@@ -0,0 +1,18 @@
import pyray as rl
class GuiStyleContext:
def __init__(self, styles: list[tuple[int, int, int]]):
"""styles is a list of tuples (control, prop, new_value)"""
self.styles = styles
self.prev_styles: list[tuple[int, int, int]] = []
def __enter__(self):
for control, prop, new_value in self.styles:
prev_value = rl.gui_get_style(control, prop)
self.prev_styles.append((control, prop, prev_value))
rl.gui_set_style(control, prop, new_value)
def __exit__(self, exc_type, exc_value, traceback):
for control, prop, prev_value in self.prev_styles:
rl.gui_set_style(control, prop, prev_value)
+762
View File
@@ -0,0 +1,762 @@
import atexit
import threading
import time
import uuid
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
from jeepney import DBusAddress, new_method_call
from jeepney.bus_messages import MatchRule, message_bus
from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking
from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading
from jeepney.low_level import MessageType
from jeepney.wrappers import Properties
from openpilot.common.swaglog import cloudlog
from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40,
NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40,
NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK,
NM_802_11_AP_SEC_KEY_MGMT_802_1X, NM_802_11_AP_FLAGS_NONE,
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_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"
SIGNAL_QUEUE_SIZE = 10
SCAN_PERIOD_SECONDS = 5
class SecurityType(IntEnum):
OPEN = 0
WPA = 1
WPA2 = 2
WPA3 = 3
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
# obtained by looking at flags of networks in the office as reported by an Android phone
supports_wpa = (NM_802_11_AP_SEC_PAIR_WEP40 | NM_802_11_AP_SEC_PAIR_WEP104 | NM_802_11_AP_SEC_GROUP_WEP40 |
NM_802_11_AP_SEC_GROUP_WEP104 | NM_802_11_AP_SEC_KEY_MGMT_PSK)
if (flags == NM_802_11_AP_FLAGS_NONE) or ((flags & NM_802_11_AP_FLAGS_WPS) and not (wpa_props & supports_wpa)):
return SecurityType.OPEN
elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & supports_wpa) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X):
return SecurityType.WPA
else:
cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}")
return SecurityType.UNSUPPORTED
@dataclass(frozen=True)
class Network:
ssid: str
strength: int
is_connected: bool
security_type: SecurityType
is_saved: bool
ip_address: str = "" # TODO: implement
@classmethod
def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network":
# we only want to show the strongest AP for each Network/SSID
strongest_ap = max(aps, key=lambda ap: ap.strength)
is_connected = any(ap.is_connected for ap in aps)
security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags)
return cls(
ssid=ssid,
strength=strongest_ap.strength,
is_connected=is_connected and is_saved,
security_type=security_type,
is_saved=is_saved,
)
@dataclass(frozen=True)
class AccessPoint:
ssid: str
bssid: str
strength: int
is_connected: bool
flags: int
wpa_flags: int
rsn_flags: int
ap_path: str
@classmethod
def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str, active_ap_path: str) -> "AccessPoint":
ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace")
bssid = str(ap_props['HwAddress'][1])
strength = int(ap_props['Strength'][1])
flags = int(ap_props['Flags'][1])
wpa_flags = int(ap_props['WpaFlags'][1])
rsn_flags = int(ap_props['RsnFlags'][1])
return cls(
ssid=ssid,
bssid=bssid,
strength=strength,
is_connected=ap_path == active_ap_path,
flags=flags,
wpa_flags=wpa_flags,
rsn_flags=rsn_flags,
ap_path=ap_path,
)
class WifiManager:
def __init__(self):
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
# DBus connections
try:
self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls
self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
except FileNotFoundError:
cloudlog.exception("Failed to connect to system D-Bus")
self._router_main = None
self._conn_monitor = None
self._exit = True
# Store wifi device path
self._wifi_device: str | None = None
# 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: 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._state_thread = threading.Thread(target=self._monitor_state, daemon=True)
self._initialize()
atexit.register(self.stop)
def _initialize(self):
def worker():
self._wait_for_wifi_device()
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
to_run, self._callback_queue = self._callback_queue, []
for cb in to_run:
cb()
def set_active(self, active: bool):
self._active = active
# Scan immediately if we haven't scanned in a while
if active and time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS / 2:
self._last_network_update = 0.0
def _monitor_state(self):
rule = MatchRule(
type="signal",
interface=NM_DEVICE_IFACE,
member="StateChanged",
path=self._wifi_device,
)
# Filter for StateChanged signal
self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule))
with self._conn_monitor.filter(rule, bufsize=SIGNAL_QUEUE_SIZE) as q:
while not self._exit:
if not self._active:
time.sleep(1)
continue
# Block until a matching signal arrives
try:
msg = self._conn_monitor.recv_until_filtered(q, timeout=1)
except TimeoutError:
continue
new_state, previous_state, change_reason = msg.body
# 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)
self._enqueue_callbacks(self._need_auth, self._connecting_to_ssid)
self._connecting_to_ssid = ""
elif new_state == NMDeviceState.ACTIVATED:
if len(self._activated):
self._update_networks()
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 = ""
self._enqueue_callbacks(self._forgotten)
def _network_scanner(self):
while not self._exit:
if self._active:
if time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS:
# Scan for networks every 10 seconds
# TODO: should update when scan is complete (PropertiesChanged), but this is more than good enough for now
self._update_networks()
self._request_scan()
self._last_network_update = time.monotonic()
time.sleep(1 / 2.)
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)
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)
known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0]
conns: dict[str, str] = {}
for conn_path in known_connections:
settings = self._get_connection_settings(conn_path)
if len(settings) == 0:
cloudlog.warning(f'Failed to get connection settings for {conn_path}')
continue
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 _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)
connection = {
'connection': {
'type': ('s', '802-11-wireless'),
'uuid': ('s', str(uuid.uuid4())),
'id': ('s', f'openpilot connection {ssid}'),
'autoconnect-retries': ('i', 0),
},
'802-11-wireless': {
'ssid': ('ay', ssid.encode("utf-8")),
'hidden': ('b', hidden),
'mode': ('s', 'infrastructure'),
},
'ipv4': {
'method': ('s', 'auto'),
'dns-priority': ('i', 600),
},
'ipv6': {'method': ('s', 'ignore')},
}
if password:
connection['802-11-wireless-security'] = {
'key-mgmt': ('s', 'wpa-psk'),
'auth-alg': ('s', 'open'),
'psk': ('s', password),
}
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,)))
self.activate_connection(ssid, block=True)
threading.Thread(target=worker, daemon=True).start()
def forget_connection(self, ssid: str, block: bool = False):
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
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 len(self._forgotten):
self._update_networks()
self._enqueue_callbacks(self._forgotten)
if block:
worker()
else:
threading.Thread(target=worker, daemon=True).start()
def activate_connection(self, ssid: str, block: bool = False):
def worker():
conn_path = self._get_connections().get(ssid, None)
if conn_path is not None:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
self._connecting_to_ssid = ssid
self._router_main.send(new_method_call(self._nm, 'ActivateConnection', 'ooo',
(conn_path, self._wifi_device, "/")))
if block:
worker()
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")
return
wifi_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_WIRELESS_IFACE)
reply = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'RequestScan', 'a{sv}', ({},)))
if reply.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to request scan: {reply}")
def _update_networks(self):
with self._lock:
if self._wifi_device is None:
cloudlog.warning("No WiFi device found")
return
# returns '/' if no active AP
wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE)
active_ap_path = self._router_main.send_and_get_reply(Properties(wifi_addr).get('ActiveAccessPoint')).body[0][1]
ap_paths = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'GetAllAccessPoints')).body[0]
aps: dict[str, list[AccessPoint]] = {}
for ap_path in ap_paths:
ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE)
ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all())
# some APs have been seen dropping off during iteration
if ap_props.header.message_type == MessageType.error:
cloudlog.warning(f"Failed to get AP properties for {ap_path}")
continue
try:
ap = AccessPoint.from_dbus(ap_props.body[0], ap_path, active_ap_path)
if ap.ssid == "":
continue
if ap.ssid not in aps:
aps[ap.ssid] = []
aps[ap.ssid].append(ap)
except Exception:
# catch all for parsing errors
cloudlog.exception(f"Failed to parse AP properties for {ap_path}")
known_connections = self._get_connections()
networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()]
# sort with quantized strength to reduce jumping
networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()))
self._networks = 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
if self._scan_thread.is_alive():
self._scan_thread.join()
if self._state_thread.is_alive():
self._state_thread.join()
if self._router_main is not None:
self._router_main.close()
self._router_main.conn.close()
if self._conn_monitor is not None:
self._conn_monitor.close()
+107
View File
@@ -0,0 +1,107 @@
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, spacing: float = 0) -> list[str]:
if not word:
return []
parts = []
remaining = word
while remaining:
if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
# Binary search for the longest substring that fits
left, right = 1, len(remaining)
best_fit = 1
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
left = mid + 1
else:
right = mid - 1
# Add the part that fits
parts.append(remaining[:best_fit])
remaining = remaining[best_fit:]
return parts
_cache: dict[int, list[str]] = {}
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
if not text or max_width <= 0:
return []
# Split text by newlines first to preserve explicit line breaks
paragraphs = text.split('\n')
all_lines: list[str] = []
for paragraph in paragraphs:
# Handle empty paragraphs (preserve empty lines)
if not paragraph.strip():
all_lines.append("")
continue
# Process each paragraph separately
words = paragraph.split()
if not words:
all_lines.append("")
continue
lines: list[str] = []
current_line: list[str] = []
for word in words:
word_width = measure_text_cached(font, word, font_size, spacing).x
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width, spacing))
continue
# 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 = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
current_line.append(word)
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
# Add all lines from this paragraph
all_lines.extend(lines)
_cache[key] = all_lines
return all_lines
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
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.widgets import Widget
from openpilot.system.ui.widgets.slider import SmallSlider
from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
NONE = 0
RESETTING = 1
FAILED = 2
class Reset(Widget):
def __init__(self, mode):
super().__init__()
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._cancel_button = SmallButton("cancel")
self._cancel_button.set_click_callback(self._cancel_callback)
self._reboot_button = FullRoundedButton("reboot")
self._reboot_button.set_click_callback(self._do_reboot)
self._confirm_slider = SmallSlider("reset", self._confirm)
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_reboot(self):
if PC:
return
os.system("sudo reboot")
def _do_erase(self):
if PC:
return
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
self._timeout_st = time.monotonic()
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50)
gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80)
gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9)
if self._reset_state != ResetState.RESETTING:
# fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel
self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage)
self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8)
if self._mode == ResetMode.RECOVER:
self._cancel_button.set_text("reboot")
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED:
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
if self._reset_state != ResetState.FAILED:
self._confirm_slider.render(rl.Rectangle(
rect.x + rect.width - self._confirm_slider.rect.width,
rect.y + rect.height - self._confirm_slider.rect.height,
self._confirm_slider.rect.width,
self._confirm_slider.rect.height))
else:
self._reboot_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._reboot_button.rect.height,
self._reboot_button.rect.width,
self._reboot_button.rect.height))
return self._render_status
def _confirm(self):
self.start_reset()
def _get_body_text(self):
if self._reset_state == ResetState.RESETTING:
return "Resetting device... This may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. It may be corrupted."
return "All content and settings will be erased."
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset")
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)):
break
if __name__ == "__main__":
main()
+760
View File
@@ -0,0 +1,760 @@
#!/usr/bin/env python3
from abc import abstractmethod
import os
import re
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
from collections.abc import Callable
import pyray as rl
from cereal import log
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton,
SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton,
FullRoundedButton)
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.slider import LargerSlider, SmallSlider
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
NetworkType = log.DeviceState.NetworkType
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class NetworkConnectivityMonitor:
def __init__(self, should_check: Callable[[], bool] | None = None, check_interval: float = 0.5):
self.network_connected = threading.Event()
self.wifi_connected = threading.Event()
self._should_check = should_check or (lambda: True)
self._check_interval = check_interval
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
def start(self):
self._stop_event.clear()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
if self._thread is not None:
self._stop_event.set()
self._thread.join()
self._thread = None
def reset(self):
self.network_connected.clear()
self.wifi_connected.clear()
def _run(self):
while not self._stop_event.is_set():
if self._should_check():
try:
request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
urllib.request.urlopen(request, timeout=0.5)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
except Exception:
self.reset()
else:
self.reset()
if self._stop_event.wait(timeout=self._check_interval):
break
class SetupState(IntEnum):
GETTING_STARTED = 0
NETWORK_SETUP = 1
NETWORK_SETUP_CUSTOM_SOFTWARE = 8
SOFTWARE_SELECTION = 2
CUSTOM_SOFTWARE = 3
DOWNLOADING = 4
DOWNLOAD_FAILED = 5
CUSTOM_SOFTWARE_WARNING = 6
class StartPage(Widget):
def __init__(self):
super().__init__()
self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224)
self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224)
def _render(self, rect: rl.Rectangle):
draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE)
self._title.render(rect)
class SoftwareSelectionPage(Widget):
def __init__(self, use_openpilot_callback: Callable,
use_custom_software_callback: Callable):
super().__init__()
self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback)
self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
def reset(self):
self._openpilot_slider.reset()
self._custom_software_slider.reset()
def _render(self, rect: rl.Rectangle):
self._openpilot_slider.set_opacity(1.0 - self._custom_software_slider.slider_percentage)
self._custom_software_slider.set_opacity(1.0 - self._openpilot_slider.slider_percentage)
openpilot_rect = rl.Rectangle(
rect.x + (rect.width - self._openpilot_slider.rect.width) / 2,
rect.y,
self._openpilot_slider.rect.width,
rect.height / 2,
)
self._openpilot_slider.render(openpilot_rect)
custom_software_rect = rl.Rectangle(
rect.x + (rect.width - self._custom_software_slider.rect.width) / 2,
rect.y + rect.height / 2,
self._custom_software_slider.rect.width,
rect.height / 2,
)
self._custom_software_slider.render(custom_software_rect)
class TermsHeader(Widget):
def __init__(self, text: str, icon_texture: rl.Texture):
super().__init__()
self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
line_height=0.8)
self._icon_texture = icon_texture
self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height))
def set_title(self, text: str):
self._title.set_text(text)
def set_icon(self, icon_texture: rl.Texture):
self._icon_texture = icon_texture
def _render(self, _):
rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y),
0.0, 1.0, rl.WHITE)
# May expand outside parent rect
title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16))
title_rect = rl.Rectangle(
self._rect.x + self._icon_texture.width + 16,
self._rect.y + (self._rect.height - title_content_height) / 2,
self._rect.width - self._icon_texture.width - 16,
title_content_height,
)
self._title.render(title_rect)
class TermsPage(Widget):
ITEM_SPACING = 20
def __init__(self, continue_callback: Callable, back_callback: Callable | None = None,
back_text: str = "back", continue_text: str = "accept"):
super().__init__()
# TODO: use Scroller
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._continue_text = continue_text
self._continue_slider: bool = continue_text in ("reboot", "power off")
self._continue_button: WideRoundedButton | FullRoundedButton | SmallSlider
if self._continue_slider:
self._continue_button = SmallSlider(continue_text, confirm_callback=continue_callback)
self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed)
elif back_callback is not None:
self._continue_button = WideRoundedButton(continue_text)
else:
self._continue_button = FullRoundedButton(continue_text)
self._continue_button.set_enabled(False)
self._continue_button.set_opacity(0.0)
self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
if not self._continue_slider:
self._continue_button.set_click_callback(continue_callback)
self._enable_back = back_callback is not None
self._back_button = SmallButton(back_text)
self._back_button.set_opacity(0.0)
self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
self._back_button.set_click_callback(back_callback)
self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78))
self._scroll_down_indicator.set_enabled(False)
def reset(self):
self._scroll_panel.set_offset(0)
self._continue_button.set_enabled(False)
self._continue_button.set_opacity(0.0)
self._back_button.set_enabled(False)
self._back_button.set_opacity(0.0)
self._scroll_down_indicator.set_opacity(1.0)
def show_event(self):
super().show_event()
self.reset()
@property
@abstractmethod
def _content_height(self):
pass
@property
def _scrolled_down_offset(self):
return -self._content_height + (self._continue_button.rect.height + 16 + 30)
@abstractmethod
def _render_content(self, scroll_offset):
pass
def _render(self, _):
scroll_offset = round(self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16))
if scroll_offset <= self._scrolled_down_offset:
# don't show back if not enabled
if self._enable_back:
self._back_button.set_enabled(True)
self._back_button.set_opacity(1.0, smooth=True)
self._continue_button.set_enabled(True)
self._continue_button.set_opacity(1.0, smooth=True)
self._scroll_down_indicator.set_opacity(0.0, smooth=True)
else:
self._back_button.set_enabled(False)
self._back_button.set_opacity(0.0, smooth=True)
self._continue_button.set_enabled(False)
self._continue_button.set_opacity(0.0, smooth=True)
self._scroll_down_indicator.set_opacity(1.0, smooth=True)
# Render content
self._render_content(scroll_offset)
# black gradient at top and bottom for scrolling content
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y),
int(self._rect.width), 20, rl.BLACK, rl.BLANK)
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20),
int(self._rect.width), 20, rl.BLANK, rl.BLACK)
# fade out back button as slider is moved
if self._continue_slider and scroll_offset <= self._scrolled_down_offset:
self._back_button.set_opacity(1.0 - self._continue_button.slider_percentage)
self._back_button.set_visible(self._continue_button.slider_percentage < 0.99)
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
continue_x = self._rect.x + 8
if self._enable_back:
continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8
if self._continue_slider:
continue_x += 8
self._continue_button.render(rl.Rectangle(
continue_x,
self._rect.y + self._rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
self._scroll_down_indicator.render(rl.Rectangle(
self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8,
self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8,
self._scroll_down_indicator.rect.width,
self._scroll_down_indicator.rect.height,
))
class CustomSoftwareWarningPage(TermsPage):
def __init__(self, continue_callback: Callable, back_callback: Callable):
super().__init__(continue_callback, back_callback)
self._title_header = TermsHeader("use caution installing\n3rd party software",
gui_app.texture("icons_mici/setup/warning.png", 66, 60))
self._body = UnifiedLabel("• It has not been tested by comma.\n" +
"• It may not comply with relevant safety standards.\n" +
"• It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60))
self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai",
36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
@property
def _content_height(self):
return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset)
self._title_header.render()
body_rect = rl.Rectangle(
self._rect.x + 8,
self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
self._rect.width - 50,
self._body.get_content_height(int(self._rect.width - 50)),
)
self._body.render(body_rect)
self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING)
self._restore_header.render()
self._restore_body.render(rl.Rectangle(
self._rect.x + 8,
self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING,
self._rect.width - 50,
self._restore_body.get_content_height(int(self._rect.width - 50)),
))
class DownloadingPage(Widget):
def __init__(self):
super().__init__()
self._title_label = UnifiedLabel("downloading", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY)
self._progress_label = UnifiedLabel("", 128, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)),
font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._progress = 0
def set_progress(self, progress: int):
self._progress = progress
self._progress_label.set_text(f"{progress}%")
def _render(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 20,
rect.y + 10,
rect.width,
64,
))
self._progress_label.render(rl.Rectangle(
rect.x + 20,
rect.y + 20,
rect.width,
rect.height,
))
class FailedPage(Widget):
def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"):
super().__init__()
self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY)
self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
font_weight=FontWeight.ROMAN)
self._reboot_button = SmallRedPillButton("reboot")
self._reboot_button.set_click_callback(reboot_callback)
self._retry_button = WideRoundedButton("retry")
self._retry_button.set_click_callback(retry_callback)
def set_reason(self, reason: str):
self._reason_label.set_text(reason)
def _render(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 10,
rect.width,
64,
))
self._reason_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 10 + 64,
rect.width,
36,
))
self._reboot_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._reboot_button.rect.height,
self._reboot_button.rect.width,
self._reboot_button.rect.height,
))
self._retry_button.render(rl.Rectangle(
rect.x + 8 + self._reboot_button.rect.width + 8,
rect.y + rect.height - self._retry_button.rect.height,
self._retry_button.rect.width,
self._retry_button.rect.height,
))
class NetworkSetupState(IntEnum):
MAIN = 0
WIFI_PANEL = 1
class NetworkSetupPage(Widget):
def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable):
super().__init__()
self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN))
self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50)
self._waiting_text = "waiting for internet..."
self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt)
back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32)
self._back_button = SmallCircleIconButton(back_txt)
self._back_button.set_click_callback(back_callback)
self._wifi_button = SmallerRoundedButton("wifi")
self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL))
self._continue_button = WidishRoundedButton("continue")
self._continue_button.set_enabled(False)
self._continue_button.set_click_callback(continue_callback)
self._state = NetworkSetupState.MAIN
self._prev_has_internet = False
def set_state(self, state: NetworkSetupState):
self._state = state
if state == NetworkSetupState.WIFI_PANEL:
self._wifi_ui.show_event()
def set_has_internet(self, has_internet: bool):
if has_internet:
self._network_header.set_title("connected to internet")
self._network_header.set_icon(self._wifi_full_txt)
self._continue_button.set_enabled(True)
else:
self._network_header.set_title(self._waiting_text)
self._network_header.set_icon(self._no_wifi_txt)
self._continue_button.set_enabled(False)
if has_internet and not self._prev_has_internet:
self.set_state(NetworkSetupState.MAIN)
self._prev_has_internet = has_internet
def show_event(self):
super().show_event()
self._state = NetworkSetupState.MAIN
self._wifi_ui.show_event()
def hide_event(self):
super().hide_event()
self._wifi_ui.hide_event()
def _render(self, _):
if self._state == NetworkSetupState.MAIN:
self._network_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16,
self._rect.width - 32,
self._network_header.rect.height,
))
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
self._wifi_button.render(rl.Rectangle(
self._rect.x + 8 + self._back_button.rect.width + 10,
self._rect.y + self._rect.height - self._wifi_button.rect.height,
self._wifi_button.rect.width,
self._wifi_button.rect.height,
))
self._continue_button.render(rl.Rectangle(
self._rect.x + self._rect.width - self._continue_button.rect.width - 8,
self._rect.y + self._rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
else:
self._wifi_ui.render(self._rect)
class Setup(Widget):
def __init__(self):
super().__init__()
self.state = SetupState.GETTING_STARTED
self.failed_url = ""
self.failed_reason = ""
self.download_url = ""
self.download_progress = 0
self.download_thread = None
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(True)
self._network_monitor = NetworkConnectivityMonitor(
lambda: self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
)
self._prev_has_internet = False
gui_app.set_modal_overlay_tick(self._modal_overlay_tick)
self._start_page = StartPage()
self._start_page.set_click_callback(self._getting_started_button_callback)
self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback,
self._network_setup_back_button_callback)
self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback,
self._software_selection_custom_software_button_callback)
self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback)
self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue,
self._custom_software_warning_back_button_callback)
self._downloading_page = DownloadingPage()
def _modal_overlay_tick(self):
has_internet = self._network_monitor.network_connected.is_set()
if has_internet and not self._prev_has_internet:
gui_app.set_modal_overlay(None)
self._prev_has_internet = has_internet
def _update_state(self):
self._wifi_manager.process_callbacks()
def _set_state(self, state: SetupState):
self.state = state
if self.state == SetupState.SOFTWARE_SELECTION:
self._software_selection_page.reset()
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self._custom_software_warning_page.reset()
if self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
self._network_setup_page.show_event()
self._network_monitor.reset()
self._network_monitor.start()
else:
self._network_setup_page.hide_event()
self._network_monitor.stop()
def _render(self, rect: rl.Rectangle):
if self.state == SetupState.GETTING_STARTED:
self._start_page.render(rect)
elif self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
self.render_network_setup(rect)
elif self.state == SetupState.SOFTWARE_SELECTION:
self._software_selection_page.render(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self._custom_software_warning_page.render(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE:
self.render_custom_software()
elif self.state == SetupState.DOWNLOADING:
self.render_downloading(rect)
elif self.state == SetupState.DOWNLOAD_FAILED:
self._download_failed_page.render(rect)
def _custom_software_warning_back_button_callback(self):
self._set_state(SetupState.SOFTWARE_SELECTION)
def _custom_software_warning_continue_button_callback(self):
self._set_state(SetupState.CUSTOM_SOFTWARE)
def _getting_started_button_callback(self):
self._set_state(SetupState.SOFTWARE_SELECTION)
def _software_selection_back_button_callback(self):
self._set_state(SetupState.GETTING_STARTED)
def _software_selection_continue_button_callback(self):
self.use_openpilot()
def _software_selection_custom_software_button_callback(self):
self._set_state(SetupState.CUSTOM_SOFTWARE_WARNING)
def _software_selection_custom_software_continue(self):
self._set_state(SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
def _download_failed_startover_button_callback(self):
self._set_state(SetupState.GETTING_STARTED)
def _network_setup_back_button_callback(self):
self._set_state(SetupState.SOFTWARE_SELECTION)
def _network_setup_continue_button_callback(self):
self._network_monitor.stop()
if self.state == SetupState.NETWORK_SETUP:
self.download(OPENPILOT_URL)
elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
self._set_state(SetupState.CUSTOM_SOFTWARE)
def close(self):
self._network_monitor.stop()
def render_network_setup(self, rect: rl.Rectangle):
self._network_setup_page.render(rect)
has_internet = self._network_monitor.network_connected.is_set()
self._prev_has_internet = has_internet
self._network_setup_page.set_has_internet(has_internet)
def render_downloading(self, rect: rl.Rectangle):
self._downloading_page.set_progress(self.download_progress)
self._downloading_page.render(rect)
def render_custom_software(self):
def handle_keyboard_result(text):
url = text.strip()
if url:
self.download(url)
def handle_keyboard_exit(result):
if result == DialogResult.CANCEL:
self._set_state(SetupState.SOFTWARE_SELECTION)
keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result)
gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self._set_state(SetupState.NETWORK_SETUP)
def download(self, url: str):
# autocomplete incomplete URLs
if re.match("^([^/.]+)/([^/]+)$", url):
url = f"https://installer.comma.ai/{url}"
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self._set_state(SetupState.DOWNLOADING)
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _download_thread(self):
try:
import tempfile
fd, tmpfile = tempfile.mkstemp(prefix="installer_")
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:
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
block_size = 8192
while True:
buffer = response.read(block_size)
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
if total_size:
self.download_progress = int(downloaded * 100 / total_size)
self._downloading_page.set_progress(self.download_progress)
is_elf = False
with open(tmpfile, 'rb') as f:
header = f.read(4)
is_elf = header == b'\x7fELF'
if not is_elf:
self.download_failed(self.download_url, "No custom software found at this URL.")
return
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
except urllib.error.HTTPError as e:
if e.code == 409:
error_msg = "Incompatible openpilot version"
self.download_failed(self.download_url, error_msg)
except Exception:
error_msg = "Invalid URL"
self.download_failed(self.download_url, error_msg)
def download_failed(self, url: str, reason: str):
self.failed_url = url
self.failed_reason = reason
self._download_failed_page.set_reason(reason)
self._set_state(SetupState.DOWNLOAD_FAILED)
def main():
try:
gui_app.init_window("Setup")
setup = Setup()
for should_render in gui_app.render():
if should_render:
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
setup.close()
except Exception as e:
print(f"Setup error: {e}")
finally:
gui_app.close()
if __name__ == "__main__":
main()
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
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.text_measure import measure_text_cached
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel
from openpilot.system.ui.widgets.button import FullRoundedButton
from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor
class Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
FAILED = 3
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
self._current_network_strength = -1
self.progress_value = 0
self.progress_text = "loading"
self.process = None
self.update_thread = None
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(True)
self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback,
self._network_setup_back_callback)
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
self._network_monitor = NetworkConnectivityMonitor()
self._network_monitor.start()
# Buttons
self._continue_button = FullRoundedButton("continue")
self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI))
self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255),
font_weight=FontWeight.DISPLAY)
self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback,
title="update failed")
def _network_setup_back_callback(self):
self.set_current_screen(Screen.PROMPT)
def _network_setup_continue_callback(self):
self.install_update()
def _update_failed_retry_callback(self):
self.set_current_screen(Screen.PROMPT)
def _on_network_updated(self, networks: list[Network]):
self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1)
def set_current_screen(self, screen: Screen):
if self.current_screen != screen:
if screen == Screen.PROGRESS:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.WIFI:
if self._network_setup_page:
self._network_setup_page.show_event()
elif screen == Screen.PROMPT:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.FAILED:
if self._network_setup_page:
self._network_setup_page.hide_event()
self.current_screen = screen
def install_update(self):
self.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "downloading"
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
for line in self.process.stdout:
parts = line.strip().split(":")
if len(parts) == 2:
self.progress_text = parts[0].lower()
try:
self.progress_value = int(float(parts[1]))
except ValueError:
pass
exit_code = self.process.wait()
if exit_code == 0:
HARDWARE.reboot()
else:
self.set_current_screen(Screen.FAILED)
def render_prompt_screen(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y - 5,
rect.width,
48,
))
subtitle_width = rect.width - 16
subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width))
self._subtitle_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 48,
subtitle_width,
subtitle_height,
))
self._continue_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8)
if ' ' in self.progress_text:
font_size = 62
else:
font_size = 82
gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
progress_value = f"{self.progress_value}%"
text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y
progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18,
self._rect.width - 12, text_height)
gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN,
color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)))
def _update_state(self):
self._wifi_manager.process_callbacks()
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
self._network_setup_page.render(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
elif self.current_screen == Screen.FAILED:
self._update_failed_page.render(rect)
def close(self):
self._network_monitor.stop()
def main():
if len(sys.argv) < 3:
print("Usage: updater.py <updater_path> <manifest_path>")
sys.exit(1)
updater_path = sys.argv[1]
manifest_path = sys.argv[2]
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
updater.close()
except Exception as e:
print(f"Updater error: {e}")
finally:
gui_app.close()
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
import openpilot.system.ui.tici_reset as tici_reset
import openpilot.system.ui.mici_reset as mici_reset
def main():
if gui_app.big_ui():
tici_reset.main()
else:
mici_reset.main()
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
import openpilot.system.ui.tici_setup as tici_setup
import openpilot.system.ui.mici_setup as mici_setup
def main():
if gui_app.big_ui():
tici_setup.main()
else:
mici_setup.main()
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
import pyray as rl
import select
import sys
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.text import wrap_text
from openpilot.system.ui.widgets import Widget
# Constants
if gui_app.big_ui():
PROGRESS_BAR_WIDTH = 1000
PROGRESS_BAR_HEIGHT = 20
TEXTURE_SIZE = 360
WRAPPED_SPACING = 50
CENTERED_SPACING = 150
else:
PROGRESS_BAR_WIDTH = 268
PROGRESS_BAR_HEIGHT = 10
TEXTURE_SIZE = 140
WRAPPED_SPACING = 10
CENTERED_SPACING = 20
DEGREES_PER_SECOND = 360.0 # one full rotation per second
MARGIN_H = 100
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value)
class Spinner(Widget):
def __init__(self):
super().__init__()
self._comma_texture = gui_app.texture("../../dragonpilot/selfdrive/assets/images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE)
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
self._rotation = 0.0
self._progress: int | None = None
self._wrapped_lines: list[str] = []
def set_text(self, text: str) -> None:
if text.isdigit():
self._progress = clamp(int(text), 0, 100)
self._wrapped_lines = []
else:
self._progress = None
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
def _render(self, rect: rl.Rectangle):
if self._wrapped_lines:
# Calculate total height required for spinner and text
spacing = WRAPPED_SPACING
total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT
center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
else:
# Center spinner vertically
spacing = CENTERED_SPACING
center_y = rect.height / 2.0
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
center = rl.Vector2(rect.width / 2.0, center_y)
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
delta_time = rl.get_frame_time()
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
# Draw rotating spinner and static comma logo
rl.draw_texture_pro(self._spinner_texture, rl.Rectangle(0, 0, TEXTURE_SIZE, TEXTURE_SIZE),
rl.Rectangle(center.x, center.y, TEXTURE_SIZE, TEXTURE_SIZE),
spinner_origin, self._rotation, rl.WHITE)
rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE)
# Display the progress bar or text based on user input
if self._progress is not None:
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
bar.width *= self._progress / 100.0
rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
elif self._wrapped_lines:
for i, line in enumerate(self._wrapped_lines):
text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE)
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
FONT_SIZE, 0.0, rl.WHITE)
def _read_stdin():
"""Non-blocking read of available lines from stdin."""
lines = []
while True:
rlist, _, _ = select.select([sys.stdin], [], [], 0.0)
if not rlist:
break
line = sys.stdin.readline().strip()
if line == "":
break
lines.append(line)
return lines
def main():
gui_app.init_window("Spinner")
spinner = Spinner()
for _ in gui_app.render():
text_list = _read_stdin()
if text_list:
spinner.set_text(text_list[-1])
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
import re
import sys
import pyray as rl
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.application import BIG_UI, 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 Button, ButtonStyle
if BIG_UI:
MARGIN = 50
SPACING = 40
FONT_SIZE = 72
LINE_HEIGHT = 80
BUTTON_SIZE = rl.Vector2(310, 160)
else:
MARGIN = 20
SPACING = 30
FONT_SIZE = 25
LINE_HEIGHT = 25
BUTTON_SIZE = rl.Vector2(150, 80)
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
The text is long enough to demonstrate scrolling and word wrapping.""" * 30
def wrap_text(text, font_size, max_width):
lines = []
font = gui_app.font()
for paragraph in text.split("\n"):
if not paragraph.strip():
# Don't add empty lines first, ensuring wrap_text("") returns []
if lines:
lines.append("")
continue
indent = re.match(r"^\s*", paragraph).group()
current_line = indent
words = re.split(r"(\s+|-)", paragraph[len(indent):])
while len(words):
word = words.pop(0)
test_line = current_line + word + (words.pop(0) if words else "")
if measure_text_cached(font, test_line, font_size).x <= max_width:
current_line = test_line
else:
lines.append(current_line)
current_line = word + " "
current_line = current_line.rstrip()
if current_line:
lines.append(current_line)
return lines
class TextWindow(Widget):
def __init__(self, text: str):
super().__init__()
self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2)
self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20)
self._content_rect = rl.Rectangle(0, 0, self._textarea_rect.width - 20, len(self._wrapped_lines) * LINE_HEIGHT)
self._scroll_panel = GuiScrollPanel()
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, font_size=FONT_SIZE)
@staticmethod
def _on_button_clicked():
gui_app.request_close()
if not PC:
import os
os.system("rm -rf /data/scons_cache/*")
HARDWARE.reboot()
def _render(self, rect: rl.Rectangle):
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, 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)
self._button.render(button_bounds)
if __name__ == "__main__":
text = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT
gui_app.init_window("Text Viewer")
text_window = TextWindow(text)
for _ in gui_app.render():
text_window.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
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
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
NONE = 0
CONFIRM = 1
RESETTING = 2
FAILED = 3
class Reset(Widget):
def __init__(self, mode):
super().__init__()
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._cancel_button = Button("Cancel", self._cancel_callback)
self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_erase(self):
if PC:
return
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
self._timeout_st = time.monotonic()
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
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 * FONT_SCALE)
gui_text_box(text_rect, self._get_body_text(), 90)
button_height = 160
button_spacing = 50
button_top = rect.y + rect.height - button_height
button_width = (rect.width - button_spacing) / 2.0
if self._reset_state != ResetState.RESETTING:
if self._mode == ResetMode.RECOVER:
self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
elif self._mode == ResetMode.USER_RESET:
self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
if self._reset_state != ResetState.FAILED:
self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
else:
self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
return self._render_status
def _confirm(self):
if self._reset_state == ResetState.CONFIRM:
self.start_reset()
else:
self._reset_state = ResetState.CONFIRM
def _get_body_text(self):
if self._reset_state == ResetState.CONFIRM:
return "Are you sure you want to reset your device?"
if self._reset_state == ResetState.RESETTING:
return "Resetting device...\nThis may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset", 20)
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
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__":
main()
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env python3
import os
import re
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
import pyray as rl
from cereal import log
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, 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
from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
NetworkType = log.DeviceState.NetworkType
MARGIN = 50
TITLE_FONT_SIZE = 90
TITLE_FONT_WEIGHT = FontWeight.MEDIUM
NEXT_BUTTON_WIDTH = 310
BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class SetupState(IntEnum):
LOW_VOLTAGE = 0
GETTING_STARTED = 1
NETWORK_SETUP = 2
SOFTWARE_SELECTION = 3
CUSTOM_SOFTWARE = 4
DOWNLOADING = 5
DOWNLOAD_FAILED = 6
CUSTOM_SOFTWARE_WARNING = 7
class Setup(Widget):
def __init__(self):
super().__init__()
self.state = SetupState.GETTING_STARTED
self.network_check_thread = None
self.network_connected = threading.Event()
self.wifi_connected = threading.Event()
self.stop_network_check_thread = threading.Event()
self.failed_url = ""
self.failed_reason = ""
self.download_url = ""
self.download_progress = 0
self.download_thread = None
self.wifi_ui = WifiManagerUI(WifiManager())
self.keyboard = Keyboard()
self.selected_radio = None
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, 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=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, 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=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)
self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
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, 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, 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, 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", 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.",
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, text_padding=20)
try:
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
voltage = float(f.read().strip()) / 1000.0
if voltage < 7:
self.state = SetupState.LOW_VOLTAGE
except (FileNotFoundError, ValueError):
self.state = SetupState.LOW_VOLTAGE
def _render(self, rect: rl.Rectangle):
if self.state == SetupState.LOW_VOLTAGE:
self.render_low_voltage(rect)
elif self.state == SetupState.GETTING_STARTED:
self.render_getting_started(rect)
elif self.state == SetupState.NETWORK_SETUP:
self.render_network_setup(rect)
elif self.state == SetupState.SOFTWARE_SELECTION:
self.render_software_selection(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self.render_custom_software_warning(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE:
self.render_custom_software()
elif self.state == SetupState.DOWNLOADING:
self.render_downloading(rect)
elif self.state == SetupState.DOWNLOAD_FAILED:
self.render_download_failed(rect)
def _low_voltage_continue_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _custom_software_warning_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _custom_software_warning_continue_button_callback(self):
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def _getting_started_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _software_selection_back_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _software_selection_continue_button_callback(self):
if self._software_selection_openpilot_button.selected:
self.use_openpilot()
else:
self.state = SetupState.CUSTOM_SOFTWARE_WARNING
def _download_failed_startover_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _network_setup_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _network_setup_continue_button_callback(self):
self.stop_network_check_thread.set()
if self._software_selection_openpilot_button.selected:
self.download(OPENPILOT_URL)
else:
self.state = SetupState.CUSTOM_SOFTWARE
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 * 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
self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
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 * 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)
triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
def check_network_connectivity(self):
while not self.stop_network_check_thread.is_set():
if self.state == SetupState.NETWORK_SETUP:
try:
urllib.request.urlopen(OPENPILOT_URL, timeout=2)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
else:
self.wifi_connected.clear()
except Exception:
self.network_connected.clear()
time.sleep(1)
def start_network_check(self):
if self.network_check_thread is None or not self.network_check_thread.is_alive():
self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
self.network_check_thread.start()
def close(self):
if self.network_check_thread is not None:
self.stop_network_check_thread.set()
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 * FONT_SCALE))
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)
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
# Check network connectivity status
continue_enabled = self.network_connected.is_set()
self._network_setup_continue_button.set_enabled(continue_enabled)
continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
self._network_setup_continue_button.set_text(continue_text)
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 * 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 * 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 * 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:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_openpilot_button.selected = False
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
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 * 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 * 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 * 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))
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
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.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 * 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 + 400, 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 < (rect.height - warn_rect.height):
self._custom_software_warning_continue_button.set_enabled(True)
self._custom_software_warning_continue_button.set_text("Continue")
def render_custom_software(self):
def handle_keyboard_result(result):
# Enter pressed
if result == 1:
url = self.keyboard.text
self.keyboard.clear()
if url:
self.download(url)
# Cancel pressed
elif result == 0:
self.state = SetupState.SOFTWARE_SELECTION
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)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def download(self, url: str):
# autocomplete incomplete URLs
if re.match("^([^/.]+)/([^/]+)$", url):
url = f"https://installer.comma.ai/{url}"
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self.state = SetupState.DOWNLOADING
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _download_thread(self):
try:
import tempfile
fd, tmpfile = tempfile.mkstemp(prefix="installer_")
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:
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
block_size = 8192
while True:
buffer = response.read(block_size)
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
if total_size:
self.download_progress = int(downloaded * 100 / total_size)
is_elf = False
with open(tmpfile, 'rb') as f:
header = f.read(4)
is_elf = header == b'\x7fELF'
if not is_elf:
self.download_failed(self.download_url, "No custom software found at this URL.")
return
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# give time for installer UI to take over
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)
def download_failed(self, url: str, reason: str):
self.failed_url = url
self.failed_reason = reason
self.state = SetupState.DOWNLOAD_FAILED
def main():
try:
gui_app.init_window("Setup", 20)
setup = Setup()
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}")
finally:
gui_app.close()
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from openpilot.system.hardware import HARDWARE
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 Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_text_box, gui_label
from openpilot.system.ui.widgets.network import WifiManagerUI
# Constants
MARGIN = 50
BUTTON_HEIGHT = 160
BUTTON_WIDTH = 400
PROGRESS_BAR_HEIGHT = 72
TITLE_FONT_SIZE = 80
BODY_FONT_SIZE = 65
BACKGROUND_COLOR = rl.BLACK
PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
class Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
self.progress_value = 0
self.progress_text = "Loading..."
self.show_reboot_button = False
self.process = None
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.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "Downloading..."
self.show_reboot_button = False
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
for line in self.process.stdout:
parts = line.strip().split(":")
if len(parts) == 2:
self.progress_text = parts[0]
try:
self.progress_value = int(float(parts[1]))
except ValueError:
pass
exit_code = self.process.wait()
if exit_code == 0:
HARDWARE.reboot()
else:
self.progress_text = "Update failed"
self.show_reboot_button = True
def render_prompt_screen(self, rect: rl.Rectangle):
# Title
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 * 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
button_y = rect.height - MARGIN - BUTTON_HEIGHT
button_width = (rect.width - MARGIN * 3) // 2
# WiFi button
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
self._wifi_button.render(wifi_button_rect)
# Install button
install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
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(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)
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)
gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
# Progress bar
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
# Calculate the width of the progress chunk
progress_width = (bar_rect.width * self.progress_value) / 100
if progress_width > 0:
progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
# 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)
self._reboot_button.render(reboot_rect)
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self.render_wifi_screen(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
def main():
if len(sys.argv) < 3:
print("Usage: updater.py <updater_path> <manifest_path>")
sys.exit(1)
updater_path = sys.argv[1]
manifest_path = sys.argv[2]
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
# Make sure we clean up even if there's an error
gui_app.close()
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from openpilot.system.ui.lib.application import gui_app
import openpilot.system.ui.tici_updater as tici_updater
import openpilot.system.ui.mici_updater as mici_updater
def main():
if gui_app.big_ui():
tici_updater.main()
else:
mici_updater.main()
if __name__ == "__main__":
main()
+386
View File
@@ -0,0 +1,386 @@
import abc
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
try:
from openpilot.selfdrive.ui.ui_state import device
except ImportError:
class Device:
awake = True
device = Device() # type: ignore
class DialogResult(IntEnum):
CANCEL = 0
CONFIRM = 1
NO_ACTION = -1
class Widget(abc.ABC):
def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle | 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._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
self.__was_awake = True
@property
def rect(self) -> rl.Rectangle:
return self._rect
def set_rect(self, rect: rl.Rectangle) -> None:
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
self._rect = rect
if changed:
self._update_layout_rects()
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
"""Can be used like size hint in QT"""
self._parent_rect = parent_rect
@property
def is_pressed(self) -> bool:
return any(self.__is_pressed)
@property
def enabled(self) -> bool:
return self._enabled() if callable(self._enabled) else self._enabled
def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._enabled = enabled
@property
def is_visible(self) -> bool:
return self._is_visible() if callable(self._is_visible) else self._is_visible
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
def _touch_valid(self) -> bool:
"""Check if the widget can be touched."""
return self._touch_valid_callback() if self._touch_valid_callback else True
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
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)
self._update_state()
if not self.is_visible:
return None
self._layout()
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
if self.enabled and self.__was_awake:
self._process_mouse_events()
self.__was_awake = device.awake
return ret
def _process_mouse_events(self) -> None:
hit_rect = self._hit_rect
touch_valid = self._touch_valid()
for mouse_event in gui_app.mouse_events:
if not self._multi_touch and mouse_event.slot != 0:
continue
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, hit_rect)
# 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 touch_valid:
if mouse_in_rect:
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
self.__tracking_is_pressed[mouse_event.slot] = True
self._handle_mouse_event(mouse_event)
# Callback such as scroll panel signifies user is scrolling
elif not touch_valid:
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released:
self._handle_mouse_event(mouse_event)
if self.__is_pressed[mouse_event.slot] and mouse_in_rect:
self._handle_mouse_release(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
# Mouse/touch is still within our rect
elif mouse_in_rect:
if self.__tracking_is_pressed[mouse_event.slot]:
self.__is_pressed[mouse_event.slot] = True
self._handle_mouse_event(mouse_event)
# Mouse/touch left our rect but may come back into focus later
elif not mouse_in_rect:
self.__is_pressed[mouse_event.slot] = False
self._handle_mouse_event(mouse_event)
def _layout(self) -> None:
"""Optionally lay out child widgets separately. This is called before rendering."""
def _update_state(self):
"""Optionally update the widget's non-layout state. This is called before rendering."""
@abc.abstractmethod
def _render(self, rect: rl.Rectangle) -> bool | int | None:
"""Render the widget within the given rectangle."""
def _update_layout_rects(self) -> None:
"""Optionally update any layout rects on Widget rect change."""
def _handle_mouse_press(self, mouse_pos: MousePos) -> None:
"""Optionally handle mouse press events."""
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
"""Optionally handle mouse release events."""
if self._click_callback:
self._click_callback()
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
"""Optionally handle mouse events. This is called before rendering."""
# Default implementation does nothing, can be overridden by subclasses
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
NAV_BAR_MARGIN = 6
NAV_BAR_WIDTH = 205
NAV_BAR_HEIGHT = 8
DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing
DISMISS_TIME_SECONDS = 1.5
class NavBar(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT))
self._alpha = 1.0
self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._fade_time = 0.0
def set_alpha(self, alpha: float) -> None:
self._alpha = alpha
self._fade_time = rl.get_time()
def show_event(self):
super().show_event()
self._alpha = 1.0
self._alpha_filter.x = 1.0
self._fade_time = rl.get_time()
def _render(self, _):
if rl.get_time() - self._fade_time > DISMISS_TIME_SECONDS:
self._alpha = 0.0
alpha = self._alpha_filter.update(self._alpha)
# white bar with black border
rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha)))
rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha)))
class NavWidget(Widget, abc.ABC):
"""
A full screen widget that supports back navigation by swiping down from the top.
"""
BACK_TOUCH_AREA_PERCENTAGE = 0.65
def __init__(self):
super().__init__()
self._back_callback: Callable[[], None] | None = None
self._back_button_start_pos: MousePos | None = None
self._swiping_away = False # currently swiping away
self._can_swipe_away = True # swipe away is blocked after certain horizontal movement
self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
self._playing_dismiss_animation = False
self._trigger_animate_in = False
self._back_enabled: bool | Callable[[], bool] = True
self._nav_bar = NavBar()
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._set_up = False
@property
def back_enabled(self) -> bool:
return self._back_enabled() if callable(self._back_enabled) else self._back_enabled
def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._back_enabled = enabled
def set_back_callback(self, callback: Callable[[], None]) -> None:
self._back_callback = callback
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if not self.back_enabled:
self._back_button_start_pos = None
self._swiping_away = False
self._can_swipe_away = True
return
if mouse_event.left_pressed:
# user is able to swipe away if starting near top of screen, or anywhere if scroller is at top
self._pos_filter.update_alpha(0.04)
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
scroller_at_top = False
vertical_scroller = False
# TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
if hasattr(self, '_scroller'):
scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal
vertical_scroller = not self._scroller._horizontal
elif hasattr(self, '_scroll_panel'):
scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
vertical_scroller = not self._scroll_panel._horizontal
# Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes
if (not vertical_scroller and in_dismiss_area) or scroller_at_top:
self._can_swipe_away = True
self._back_button_start_pos = mouse_event.pos
elif mouse_event.left_down:
if self._back_button_start_pos is not None:
# block swiping away if too much horizontal or upward movement
horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
if not self._swiping_away and (horizontal_movement or upward_movement):
self._can_swipe_away = False
self._back_button_start_pos = None
# block horizontal swiping if now swiping away
if self._can_swipe_away:
if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore
self._swiping_away = True
elif mouse_event.left_released:
self._pos_filter.update_alpha(0.1)
# if far enough, trigger back navigation callback
if self._back_button_start_pos is not None:
if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD:
self._playing_dismiss_animation = True
self._back_button_start_pos = None
self._swiping_away = False
def _update_state(self):
super()._update_state()
# Disable self's scroller while swiping away
if not self._set_up:
self._set_up = True
if hasattr(self, '_scroller'):
original_enabled = self._scroller._enabled
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
elif hasattr(self, '_scroll_panel'):
original_enabled = self._scroll_panel.enabled
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
original_enabled))
if self._trigger_animate_in:
self._pos_filter.x = self._rect.height
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
self._trigger_animate_in = False
new_y = 0.0
if self._back_button_start_pos is not None:
last_mouse_event = gui_app.last_mouse_event
# push entire widget as user drags it away
new_y = max(last_mouse_event.pos.y - self._back_button_start_pos.y, 0)
if new_y < SWIPE_AWAY_THRESHOLD:
new_y /= 2 # resistance until mouse release would dismiss widget
if self._swiping_away:
self._nav_bar.set_alpha(1.0)
if self._playing_dismiss_animation:
new_y = self._rect.height + DISMISS_PUSH_OFFSET
new_y = round(self._pos_filter.update(new_y))
if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0:
new_y = self._pos_filter.x = 0.0
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
if self._back_callback is not None:
self._back_callback()
self._playing_dismiss_animation = False
self._back_button_start_pos = None
self._swiping_away = False
self.set_position(self._rect.x, new_y)
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
ret = super().render(rect)
if self.back_enabled:
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
if self._back_button_start_pos is not None or self._playing_dismiss_animation:
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x
else:
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
self._nav_bar.render()
# draw black above widget when dismissing
if self._rect.y > 0:
rl.draw_rectangle(int(self._rect.x), 0, int(self._rect.width), int(self._rect.y), rl.BLACK)
return ret
def show_event(self):
super().show_event()
# FIXME: we don't know the height of the rect at first show_event since it's before the first render :(
# so we need this hacky bool for now
self._trigger_animate_in = True
self._nav_bar.show_event()
+303
View File
@@ -0,0 +1,303 @@
from collections.abc import Callable
from enum import IntEnum
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import Label, UnifiedLabel
from openpilot.common.filter_simple import FirstOrderFilter
class ButtonStyle(IntEnum):
NORMAL = 0 # Most common, neutral buttons
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
KEYBOARD = 7
FORGET_WIFI = 8
ICON_PADDING = 15
DEFAULT_BUTTON_FONT_SIZE = 60
ACTION_BUTTON_FONT_SIZE = 48
BUTTON_TEXT_COLOR = {
ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255),
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.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),
ButtonStyle.KEYBOARD: rl.Color(221, 221, 221, 255),
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(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),
ButtonStyle.KEYBOARD: rl.Color(68, 68, 68, 255),
ButtonStyle.FORGET_WIFI: rl.Color(189, 189, 189, 255),
}
BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(74, 74, 74, 255),
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),
ButtonStyle.KEYBOARD: rl.Color(51, 51, 51, 255),
ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255),
}
BUTTON_DISABLED_BACKGROUND_COLORS = {
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
}
class Button(Widget):
def __init__(self,
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: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_padding: int = 20,
icon=None,
elide_right: bool = False,
multi_touch: bool = False,
):
super().__init__()
self._button_style = button_style
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=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
def set_text(self, text):
self._label.set_text(text)
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:
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
if self.is_pressed:
self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
else:
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
elif self._button_style != ButtonStyle.NO_EFFECT:
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)
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)
class ButtonRadio(Button):
def __init__(self,
text: str,
icon,
click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
border_radius: int = 10,
text_padding: int = 20,
):
super().__init__(text, click_callback=click_callback, font_size=font_size,
border_radius=border_radius, text_padding=text_padding,
text_alignment=text_alignment)
self._text_padding = text_padding
self._icon = icon
self.selected = False
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.selected = not self.selected
def _update_state(self):
if self.selected:
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.PRIMARY]
else:
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.NORMAL]
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)
self._label.render(self._rect)
if self._icon and self.selected:
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
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height))
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, rect: rl.Rectangle):
color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE
if not self.enabled:
color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
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)
class SmallCircleIconButton(Widget):
def __init__(self, icon_txt: rl.Texture):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 100, 100))
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._icon_bg_txt = gui_app.texture("icons_mici/setup/small_button.png", 100, 100)
self._icon_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_button_pressed.png", 100, 100)
self._icon_bg_disabled_txt = gui_app.texture("icons_mici/setup/small_button_disabled.png", 100, 100)
self._icon_txt = icon_txt
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
if not self.enabled:
bg_txt = self._icon_bg_disabled_txt
icon_white = rl.Color(255, 255, 255, int(white.a * 0.35))
else:
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
icon_white = white
rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white)
icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2
icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2
rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), icon_white)
class SmallButton(Widget):
def __init__(self, text: str):
super().__init__()
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._load_assets()
self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._bg_disabled_txt = None
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
def set_text(self, text: str):
self._label.set_text(text)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
if not self.enabled and self._bg_disabled_txt is not None:
rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
elif self.is_pressed:
rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
else:
rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
opacity = 0.9 if self.enabled else 0.35
self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
self._label.render(self._rect)
class SmallRedPillButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
class SmallerRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 150, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
class WideRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 316, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
class WidishRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 250, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
class FullRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)
+94
View File
@@ -0,0 +1,94 @@
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 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_tici import Scroller
OUTER_MARGIN = 200
RICH_OUTER_MARGIN = 100
BUTTON_HEIGHT = 160
MARGIN = 50
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 | None = None, rich: bool = False):
super().__init__()
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):
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
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
def _render(self, rect: rl.Rectangle):
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
cancel_button_x = dialog_rect.x + MARGIN
confirm_button_x = dialog_rect.x + dialog_rect.width - button_width - MARGIN
button_y = bottom - BUTTON_HEIGHT - MARGIN
cancel_button = rl.Rectangle(cancel_button_x, button_y, button_width, BUTTON_HEIGHT)
confirm_button = rl.Rectangle(confirm_button_x, button_y, button_width, BUTTON_HEIGHT)
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
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
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
self._dialog_result = DialogResult.CANCEL
if self._cancel_text:
self._confirm_button.render(confirm_button)
self._cancel_button.render(cancel_button)
else:
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 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="")
+290
View File
@@ -0,0 +1,290 @@
import re
import pyray as rl
from dataclasses import dataclass
from enum import Enum
from typing import Any
from openpilot.system.ui.lib.application import gui_app, FontWeight, 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
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):
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
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
margin_top: int
margin_bottom: int
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 | 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._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": 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.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:
content = file.read()
self.parse_html_content(content)
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 = COMMENT_RE.sub('', html_content)
# Remove DOCTYPE, html, head, body tags but keep their content
html_content = DOCTYPE_RE.sub('', html_content)
html_content = HTML_BODY_TAGS_RE.sub('', html_content)
# 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)
else:
current_content.append(token)
if current_content:
close_tag()
def _add_element(self, element_type: ElementType, content: str) -> None:
style = self.styles[element_type]
element = HtmlElement(
type=element_type,
content=content,
font_size=style["size"],
font_weight=style["weight"],
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):
# TODO: speed up by removing duplicate calculations across renders
current_y = rect.y
padding = 20
content_width = rect.width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
current_y += element.margin_bottom
continue
current_y += element.margin_top
if current_y > rect.y + rect.height:
break
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width))
for line in wrapped_lines:
# 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
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)
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
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)
for element in self.elements:
if element.type == ElementType.BR:
total_height += element.margin_bottom
continue
total_height += element.margin_top
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width))
for _ in wrapped_lines:
total_height += element.font_size * 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
+228
View File
@@ -0,0 +1,228 @@
import pyray as rl
import time
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
PASSWORD_MASK_CHAR = ""
PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking
class InputBox(Widget):
def __init__(self, max_text_size=255, password_mode=False):
super().__init__()
self._max_text_size = max_text_size
self._input_text = ""
self._cursor_position = 0
self._password_mode = password_mode
self._blink_counter = 0
self._show_cursor = False
self._last_key_pressed = 0
self._key_press_time = 0
self._repeat_delay = 30
self._repeat_rate = 4
self._text_offset = 0
self._visible_width = 0
self._last_char_time = 0 # Track when last character was added
self._masked_length = 0 # How many characters are currently masked
@property
def text(self):
return self._input_text
@text.setter
def text(self, value):
self._input_text = value[: self._max_text_size]
self._cursor_position = len(self._input_text)
self._update_text_offset()
def set_password_mode(self, password_mode):
self._password_mode = password_mode
def clear(self):
self._input_text = ''
self._cursor_position = 0
self._text_offset = 0
def set_cursor_position(self, position):
"""Set the cursor position and reset the blink counter."""
if 0 <= position <= len(self._input_text):
self._cursor_position = position
self._blink_counter = 0
self._show_cursor = True
self._update_text_offset()
def _update_text_offset(self):
"""Ensure the cursor is visible by adjusting text offset."""
if self._visible_width == 0:
return
font = gui_app.font()
display_text = self._get_display_text()
padding = 10
if self._cursor_position > 0:
cursor_x = measure_text_cached(font, display_text[: self._cursor_position], self._font_size).x
else:
cursor_x = 0
visible_width = self._visible_width - (padding * 2)
# Adjust offset if cursor would be outside visible area
if cursor_x < self._text_offset:
self._text_offset = max(0, cursor_x - padding)
elif cursor_x > self._text_offset + visible_width:
self._text_offset = cursor_x - visible_width + padding
def add_char_at_cursor(self, char):
"""Add a character at the current cursor position."""
if len(self._input_text) < self._max_text_size:
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position + 1)
if self._password_mode:
self._last_char_time = time.monotonic()
return True
return False
def delete_char_before_cursor(self):
"""Delete the character before the cursor position (backspace)."""
if self._cursor_position > 0:
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position - 1)
return True
return False
def delete_char_at_cursor(self):
"""Delete the character at the cursor position (delete)."""
if self._cursor_position < len(self._input_text):
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1:]
self.set_cursor_position(self._cursor_position)
return True
return False
def _render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
# Store dimensions for text offset calculations
self._visible_width = rect.width
self._font_size = font_size
# Draw input box
rl.draw_rectangle_rec(rect, color)
# Process keyboard input
self._handle_keyboard_input()
# Update cursor blink
self._blink_counter += 1
if self._blink_counter >= 30:
self._show_cursor = not self._show_cursor
self._blink_counter = 0
# Display text
font = gui_app.font()
display_text = self._get_display_text()
padding = 10
# Clip text within input box bounds
buffer = 2
rl.begin_scissor_mode(int(rect.x + padding - buffer), int(rect.y), int(rect.width - padding * 2 + buffer * 2), int(rect.height))
rl.draw_text_ex(
font,
display_text,
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,
)
# Draw cursor
if self._show_cursor:
cursor_x = rect.x + padding
if len(display_text) > 0 and self._cursor_position > 0:
cursor_x += measure_text_cached(font, display_text[: self._cursor_position], font_size).x
# Apply text offset to cursor position
cursor_x -= self._text_offset
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)
rl.end_scissor_mode()
def _get_display_text(self):
"""Get text to display, applying password masking with delay if needed."""
if not self._password_mode:
return self._input_text
# Show character at last edited position if within delay window
masked_text = PASSWORD_MASK_CHAR * len(self._input_text)
recent_edit = time.monotonic() - self._last_char_time < PASSWORD_MASK_DELAY
if recent_edit and self._input_text:
last_pos = max(0, self._cursor_position - 1)
if last_pos < len(self._input_text):
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1:]
return masked_text
def _handle_mouse_release(self, mouse_pos: MousePos):
# Calculate cursor position from click
if len(self._input_text) > 0:
font = gui_app.font()
display_text = self._get_display_text()
# Find the closest character position to the click
relative_x = mouse_pos.x - (self._rect.x + 10) + self._text_offset
best_pos = 0
min_distance = float('inf')
for i in range(len(self._input_text) + 1):
char_width = measure_text_cached(font, display_text[:i], self._font_size).x
distance = abs(relative_x - char_width)
if distance < min_distance:
min_distance = distance
best_pos = i
self.set_cursor_position(best_pos)
else:
self.set_cursor_position(0)
def _handle_keyboard_input(self):
# Handle navigation keys
key = rl.get_key_pressed()
if key != 0:
self._process_key(key)
if key in (rl.KEY_LEFT, rl.KEY_RIGHT, rl.KEY_BACKSPACE, rl.KEY_DELETE):
self._last_key_pressed = key
self._key_press_time = 0
# Handle repeats for held keys
elif self._last_key_pressed != 0:
if rl.is_key_down(self._last_key_pressed):
self._key_press_time += 1
if self._key_press_time > self._repeat_delay and self._key_press_time % self._repeat_rate == 0:
self._process_key(self._last_key_pressed)
else:
self._last_key_pressed = 0
# Handle text input
char = rl.get_char_pressed()
if char != 0 and char >= 32: # Filter out control characters
self.add_char_at_cursor(chr(char))
def _process_key(self, key):
if key == rl.KEY_LEFT:
if self._cursor_position > 0:
self.set_cursor_position(self._cursor_position - 1)
elif key == rl.KEY_RIGHT:
if self._cursor_position < len(self._input_text):
self.set_cursor_position(self._cursor_position + 1)
elif key == rl.KEY_BACKSPACE:
self.delete_char_before_cursor()
elif key == rl.KEY_DELETE:
self.delete_char_at_cursor()
elif key == rl.KEY_HOME:
self.set_cursor_position(0)
elif key == rl.KEY_END:
self.set_cursor_position(len(self._input_text))
+273
View File
@@ -0,0 +1,273 @@
from functools import partial
import time
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
KEY_FONT_SIZE = 96
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
DELETE_REPEAT_DELAY = 0.5
DELETE_REPEAT_INTERVAL = 0.07
# Constants for special keys
CONTENT_MARGIN = 50
BACKSPACE_KEY = "<-"
ENTER_KEY = "->"
SPACE_KEY = " "
SHIFT_INACTIVE_KEY = "SHIFT_OFF"
SHIFT_ACTIVE_KEY = "SHIFT_ON"
CAPS_LOCK_KEY = "CAPS"
NUMERIC_KEY = "123"
SYMBOL_KEY = "#+="
ABC_KEY = "ABC"
# Define keyboard layouts as a dictionary for easier access
KEYBOARD_LAYOUTS = {
"lowercase": [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
[SHIFT_INACTIVE_KEY, "z", "x", "c", "v", "b", "n", "m", BACKSPACE_KEY],
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
],
"uppercase": [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
[SHIFT_ACTIVE_KEY, "Z", "X", "C", "V", "B", "N", "M", BACKSPACE_KEY],
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
],
"numbers": [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
[SYMBOL_KEY, "_", ",", "?", "!", "`", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
"specials": [
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", ""],
[NUMERIC_KEY, "-", ",", "?", "!", "'", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
}
class Keyboard(Widget):
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False):
super().__init__()
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
self._caps_lock = False
self._last_shift_press_time = 0
self._title = 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
self._input_box = InputBox(max_text_size)
self._password_mode = password_mode
self._show_password_toggle = show_password_toggle
# Backspace key repeat tracking
self._backspace_pressed: bool = False
self._backspace_press_time: float = 0.0
self._backspace_last_repeat: float = 0.0
self._render_return_status = -1
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
self._eye_open_texture = gui_app.texture("icons/eye_open.png", 81, 54)
self._eye_closed_texture = gui_app.texture("icons/eye_closed.png", 81, 54)
self._key_icons = {
BACKSPACE_KEY: gui_app.texture("icons/backspace.png", 80, 80),
SHIFT_INACTIVE_KEY: gui_app.texture("icons/shift.png", 80, 80),
SHIFT_ACTIVE_KEY: gui_app.texture("icons/shift-fill.png", 80, 80),
CAPS_LOCK_KEY: gui_app.texture("icons/capslock-fill.png", 80, 80),
ENTER_KEY: gui_app.texture("icons/arrow-right.png", 80, 80),
}
self._all_keys = {}
for l in KEYBOARD_LAYOUTS:
for _, keys in enumerate(KEYBOARD_LAYOUTS[l]):
for _, key in enumerate(keys):
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)
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
def clear(self):
self._layout_name = "lowercase"
self._caps_lock = False
self._input_box.clear()
self._backspace_pressed = False
def set_title(self, title: str, sub_title: str = ""):
self._title.set_text(title)
self._sub_title.set_text(sub_title)
def _eye_button_callback(self):
self._password_mode = not self._password_mode
def _cancel_button_callback(self):
self.clear()
self._render_return_status = 0
def _key_callback(self, k):
if k == ENTER_KEY:
self._render_return_status = 1
else:
self.handle_key_press(k)
def _render(self, rect: rl.Rectangle):
rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN)
self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95))
self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))
self._cancel_button.render(rl.Rectangle(rect.x + rect.width - 386, rect.y, 386, 125))
# Draw input box and password toggle
input_margin = 25
input_box_rect = rl.Rectangle(rect.x + input_margin, rect.y + 160, rect.width - input_margin, 100)
self._render_input_area(input_box_rect)
# Process backspace key repeat if it's held down
if not self._all_keys[BACKSPACE_KEY].is_pressed:
self._backspace_pressed = False
if self._backspace_pressed:
current_time = time.monotonic()
time_since_press = current_time - self._backspace_press_time
# After initial delay, start repeating with shorter intervals
if time_since_press > DELETE_REPEAT_DELAY:
time_since_last_repeat = current_time - self._backspace_last_repeat
if time_since_last_repeat > DELETE_REPEAT_INTERVAL:
self._input_box.delete_char_before_cursor()
self._backspace_last_repeat = current_time
layout = KEYBOARD_LAYOUTS[self._layout_name]
h_space, v_space = 15, 15
row_y_start = rect.y + 300 # Starting Y position for the first row
key_height = (rect.height - 300 - 3 * v_space) / 4
key_max_width = (rect.width - (len(layout[2]) - 1) * h_space) / len(layout[2])
# Iterate over the rows of keys in the current layout
for row, keys in enumerate(layout):
key_width = min((rect.width - (180 if row == 1 else 0) - h_space * (len(keys) - 1)) / len(keys), key_max_width)
start_x = rect.x + (90 if row == 1 else 0)
for i, key in enumerate(keys):
if i > 0:
start_x += h_space
new_width = (key_width * 3 + h_space * 2) if key == SPACE_KEY else (key_width * 2 + h_space if key == ENTER_KEY else key_width)
key_rect = rl.Rectangle(start_x, row_y_start + row * (key_height + v_space), new_width, key_height)
start_x += new_width
is_enabled = key != ENTER_KEY or len(self._input_box.text) >= self._min_text_size
if key == BACKSPACE_KEY and self._all_keys[BACKSPACE_KEY].is_pressed and not self._backspace_pressed:
self._backspace_pressed = True
self._backspace_press_time = time.monotonic()
self._backspace_last_repeat = time.monotonic()
if key in self._key_icons:
if key == SHIFT_ACTIVE_KEY and self._caps_lock:
key = CAPS_LOCK_KEY
self._all_keys[key].set_enabled(is_enabled)
self._all_keys[key].render(key_rect)
else:
self._all_keys[key].set_enabled(is_enabled)
self._all_keys[key].render(key_rect)
return self._render_return_status
def _render_input_area(self, input_rect: rl.Rectangle):
if self._show_password_toggle:
self._input_box.set_password_mode(self._password_mode)
self._input_box.render(rl.Rectangle(input_rect.x, input_rect.y, input_rect.width - 100, input_rect.height))
# render eye icon
eye_texture = self._eye_closed_texture if self._password_mode else self._eye_open_texture
eye_rect = rl.Rectangle(input_rect.x + input_rect.width - 90, input_rect.y, 80, input_rect.height)
self._eye_button.render(eye_rect)
eye_x = eye_rect.x + (eye_rect.width - eye_texture.width) / 2
eye_y = eye_rect.y + (eye_rect.height - eye_texture.height) / 2
rl.draw_texture_v(eye_texture, rl.Vector2(eye_x, eye_y), rl.WHITE)
else:
self._input_box.render(input_rect)
rl.draw_line_ex(
rl.Vector2(input_rect.x, input_rect.y + input_rect.height - 2),
rl.Vector2(input_rect.x + input_rect.width, input_rect.y + input_rect.height - 2),
3.0, # 3 pixel thickness
rl.Color(189, 189, 189, 255),
)
def handle_key_press(self, key):
if key in (CAPS_LOCK_KEY, ABC_KEY):
self._caps_lock = False
self._layout_name = "lowercase"
elif key == SHIFT_INACTIVE_KEY:
self._last_shift_press_time = time.monotonic()
self._layout_name = "uppercase"
elif key == SHIFT_ACTIVE_KEY:
if time.monotonic() - self._last_shift_press_time < DOUBLE_CLICK_THRESHOLD:
self._caps_lock = True
else:
self._layout_name = "lowercase"
elif key == NUMERIC_KEY:
self._layout_name = "numbers"
elif key == SYMBOL_KEY:
self._layout_name = "specials"
elif key == BACKSPACE_KEY:
self._input_box.delete_char_before_cursor()
else:
self._input_box.add_char_at_cursor(key)
if not self._caps_lock and self._layout_name == "uppercase":
self._layout_name = "lowercase"
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()
if __name__ == "__main__":
gui_app.init_window("Keyboard")
keyboard = Keyboard(min_text_size=8, show_password_toggle=True)
for _ in gui_app.render():
keyboard.set_title("Keyboard Input", "Type your text below")
result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result == 1:
print(f"You typed: {keyboard.text}")
gui_app.request_close()
elif result == 0:
print("Canceled")
gui_app.request_close()
gui_app.close()
+796
View File
@@ -0,0 +1,796 @@
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, FONT_SCALE
from openpilot.system.ui.widgets import Widget
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
from openpilot.system.ui.lib.wrap_text import wrap_text
ICON_PADDING = 15
# TODO: make this common
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
class ScrollState(IntEnum):
STARTING = 0
SCROLLING = 1
# TODO: merge anything new here to master
class MiciLabel(Widget):
def __init__(self,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
width: int = None,
color: rl.Color = DEFAULT_TEXT_COLOR,
font_weight: FontWeight = FontWeight.NORMAL,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
spacing: int = 0,
line_height: int = None,
elide_right: bool = True,
wrap_text: bool = False,
scroll: bool = False):
super().__init__()
self.text = text
self.wrapped_text: list[str] = []
self.font_size = font_size
self.width = width
self.color = color
self.font_weight = font_weight
self.alignment = alignment
self.alignment_vertical = alignment_vertical
self.spacing = spacing
self.line_height = line_height if line_height is not None else font_size
self.elide_right = elide_right
self.wrap_text = wrap_text
self._height = 0
# Scroll state
self.scroll = scroll
self._needs_scroll = False
self._scroll_offset = 0
self._scroll_pause_t: float | None = None
self._scroll_state: ScrollState = ScrollState.STARTING
assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
self.set_text(text)
@property
def text_height(self):
return self._height
def set_font_size(self, font_size: int):
self.font_size = font_size
self.set_text(self.text)
def set_width(self, width: int):
self.width = width
self._rect.width = width
self.set_text(self.text)
def set_text(self, txt: str):
self.text = txt
text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
if self.width is not None:
self._rect.width = self.width
else:
self._rect.width = text_size.x
if self.wrap_text:
self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
self._height = len(self.wrapped_text) * self.line_height
elif self.scroll:
self._needs_scroll = self.scroll and text_size.x > self._rect.width
self._rect.height = text_size.y
def set_color(self, color: rl.Color):
self.color = color
def set_font_weight(self, font_weight: FontWeight):
self.font_weight = font_weight
self.set_text(self.text)
def _render(self, rect: rl.Rectangle):
# Only scissor when we know there is a single scrolling line
if self._needs_scroll:
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
font = gui_app.font(self.font_weight)
text_y_offset = 0
# Draw the text in the specified rectangle
lines = self.wrapped_text or [self.text]
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
lines = lines[::-1]
for display_text in lines:
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
# Elide text to fit within the rectangle
if self.elide_right and text_size.x > rect.width:
ellipsis = "..."
left, right = 0, len(display_text)
while left < right:
mid = (left + right) // 2
candidate = display_text[:mid] + ellipsis
candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
if candidate_size.x <= rect.width:
left = mid + 1
else:
right = mid
display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
# Handle scroll state
elif self.scroll and self._needs_scroll:
if self._scroll_state == ScrollState.STARTING:
if self._scroll_pause_t is None:
self._scroll_pause_t = rl.get_time() + 2.0
if rl.get_time() >= self._scroll_pause_t:
self._scroll_state = ScrollState.SCROLLING
self._scroll_pause_t = None
elif self._scroll_state == ScrollState.SCROLLING:
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
# don't fully hide
if self._scroll_offset <= -text_size.x - self._rect.width / 3:
self._scroll_offset = 0
self._scroll_state = ScrollState.STARTING
self._scroll_pause_t = None
# Calculate horizontal position based on alignment
text_x = rect.x + {
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
}.get(self.alignment, 0) + self._scroll_offset
# Calculate vertical position based on alignment
text_y = rect.y + {
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
}.get(self.alignment_vertical, 0)
text_y += text_y_offset
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color)
# Draw 2nd instance for scrolling
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
text2_scroll_offset = text_size.x + self._rect.width / 3
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color)
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
text_y_offset -= self.line_height
else:
text_y_offset += self.line_height
if self._needs_scroll:
# draw black fade on left and right
fade_width = 20
rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
if self._scroll_state != ScrollState.STARTING:
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)
rl.end_scissor_mode()
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
color: rl.Color = DEFAULT_TEXT_COLOR,
font_weight: FontWeight = FontWeight.NORMAL,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
elide_right: bool = True
):
font = gui_app.font(font_weight)
text_size = measure_text_cached(font, text, font_size)
display_text = text
# Elide text to fit within the rectangle
if elide_right and text_size.x > rect.width:
_ellipsis = "..."
left, right = 0, len(text)
while left < right:
mid = (left + right) // 2
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
text_size = measure_text_cached(font, display_text, font_size)
# Calculate horizontal position based on alignment
text_x = rect.x + {
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
}.get(alignment, 0)
# Calculate vertical position based on alignment
text_y = rect.y + {
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
}.get(alignment_vertical, 0)
# Draw the text in the specified rectangle
# TODO: add wrapping and proper centering for multiline text
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
def gui_text_box(
rect: rl.Rectangle,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
color: rl.Color = DEFAULT_TEXT_COLOR,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
font_weight: FontWeight = FontWeight.NORMAL,
line_scale: float = 1.0,
):
styles = [
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
(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 * line_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)
]
if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(font_weight))
with GuiStyleContext(styles):
rl.gui_label(rect, text)
if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(FontWeight.NORMAL))
# Non-interactive text area. Can render emojis and an optional specified icon.
class Label(Widget):
def __init__(self,
text: str | Callable[[], str],
font_size: int = DEFAULT_TEXT_SIZE,
font_weight: FontWeight = FontWeight.NORMAL,
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: Union[rl.Texture, None] = None,
elide_right: bool = False,
line_scale=1.0,
):
super().__init__()
self._font_weight = font_weight
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._line_scale = line_scale
self._text = text
self.set_text(text)
def set_text(self, text):
self._text = text
self._update_text(self._text)
def set_text_color(self, color):
self._text_color = color
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 = []
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 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)
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 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 == 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
else:
icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - ICON_PADDING - self._icon.width
else:
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_wrapped, self._text_size, self._emojis, fillvalue=[]):
line_pos = rl.Vector2(text_pos.x, text_pos.y)
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:
text_before = text[prev_index:start]
width_before = measure_text_cached(self._font, text_before, self._font_size)
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color)
line_pos.x += width_before.x
tex = emoji_tex(emoji)
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 * FONT_SCALE) * self._line_scale
class UnifiedLabel(Widget):
"""
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
Supports:
- Emoji rendering
- Text wrapping
- Automatic eliding (single-line or multiline)
- Proper multiline vertical alignment
- Height calculation for layout purposes
"""
def __init__(self,
text: str | Callable[[], str],
font_size: int = DEFAULT_TEXT_SIZE,
font_weight: FontWeight = FontWeight.NORMAL,
text_color: rl.Color = DEFAULT_TEXT_COLOR,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
text_padding: int = 0,
max_width: int | None = None,
elide: bool = True,
wrap_text: bool = True,
scroll: bool = False,
line_height: float = 1.0,
letter_spacing: float = 0.0):
super().__init__()
self._text = text
self._font_size = font_size
self._font_weight = font_weight
self._font = gui_app.font(self._font_weight)
self._text_color = text_color
self._alignment = alignment
self._alignment_vertical = alignment_vertical
self._text_padding = text_padding
self._max_width = max_width
self._elide = elide
self._wrap_text = wrap_text
self._scroll = scroll
self._line_height = line_height * 0.9
self._letter_spacing = letter_spacing # 0.1 = 10%
self._spacing_pixels = font_size * letter_spacing
# Scroll state
self._scroll = scroll
self._needs_scroll = False
self._scroll_offset = 0
self._scroll_pause_t: float | None = None
self._scroll_state: ScrollState = ScrollState.STARTING
# Scroll mode does not support eliding or multiline wrapping
if self._scroll:
self._elide = False
self._wrap_text = False
# Cached data
self._cached_text: str | None = None
self._cached_wrapped_lines: list[str] = []
self._cached_line_sizes: list[rl.Vector2] = []
self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
self._cached_total_height: float | None = None
self._cached_width: int = -1
# If max_width is set, initialize rect size for Scroller support
if max_width is not None:
self._rect.width = max_width
self._rect.height = self.get_content_height(max_width)
def set_text(self, text: str | Callable[[], str]):
"""Update the text content."""
self._text = text
# No need to update cache here, will be done on next render if needed
@property
def text(self) -> str:
"""Get the current text content."""
return str(_resolve_value(self._text))
def set_text_color(self, color: rl.Color):
"""Update the text color."""
self._text_color = color
def set_color(self, color: rl.Color):
"""Update the text color (alias for set_text_color)."""
self.set_text_color(color)
def set_font_size(self, size: int):
"""Update the font size."""
if self._font_size != size:
self._font_size = size
self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
self._cached_text = None # Invalidate cache
def set_letter_spacing(self, letter_spacing: float):
"""Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
if self._letter_spacing != letter_spacing:
self._letter_spacing = letter_spacing
self._spacing_pixels = self._font_size * letter_spacing
self._cached_text = None # Invalidate cache
def set_font_weight(self, font_weight: FontWeight):
"""Update the font weight."""
if self._font_weight != font_weight:
self._font_weight = font_weight
self._font = gui_app.font(self._font_weight)
self._cached_text = None # Invalidate cache
def set_alignment(self, alignment: int):
"""Update the horizontal text alignment."""
self._alignment = alignment
def set_alignment_vertical(self, alignment_vertical: int):
"""Update the vertical text alignment."""
self._alignment_vertical = alignment_vertical
def reset_scroll(self):
"""Reset scroll state to initial position."""
self._scroll_offset = 0
self._scroll_pause_t = None
self._scroll_state = ScrollState.STARTING
def set_max_width(self, max_width: int | None):
"""Set the maximum width constraint for wrapping/eliding."""
if self._max_width != max_width:
self._max_width = max_width
self._cached_text = None # Invalidate cache
# Update rect size for Scroller support
if max_width is not None:
self._rect.width = max_width
self._rect.height = self.get_content_height(max_width)
def _update_text_cache(self, available_width: int):
"""Update cached text processing data."""
text = self.text
# Check if cache is still valid
if (self._cached_text == text and
self._cached_width == available_width and
self._cached_wrapped_lines):
return
self._cached_text = text
self._cached_width = available_width
# Determine wrapping width
content_width = available_width - (self._text_padding * 2)
if content_width <= 0:
content_width = 1
# Wrap text if enabled
if self._wrap_text:
self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
else:
# Split by newlines but don't wrap
self._cached_wrapped_lines = text.split('\n') if text else [""]
# Elide lines if needed (for width constraint)
self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
if self._scroll:
self._cached_wrapped_lines = self._cached_wrapped_lines[:1] # Only first line for scrolling
# Process each line: measure and find emojis
self._cached_line_sizes = []
self._cached_line_emojis = []
for line in self._cached_wrapped_lines:
emojis = find_emoji(line)
self._cached_line_emojis.append(emojis)
# Empty lines should still have height (use font size as line height)
if not line:
size = rl.Vector2(0, self._font_size * FONT_SCALE)
else:
size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
# This is the only line
if self._scroll:
self._needs_scroll = size.x > content_width
self._cached_line_sizes.append(size)
# Calculate total height
# Each line contributes its measured height * line_height (matching Label's behavior)
# This includes spacing to the next line
if self._cached_line_sizes:
# Match the rendering logic: first line doesn't get line_height scaling
total_height = 0.0
for idx, size in enumerate(self._cached_line_sizes):
if idx == 0:
total_height += size.y
else:
total_height += size.y * self._line_height
self._cached_total_height = total_height
else:
self._cached_total_height = 0.0
def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
"""Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
if not self._elide and not force:
return line
text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
if text_size.x <= max_width and not force:
return line
ellipsis = "..."
# If force=True and line fits, just append ellipsis without truncating
if force and text_size.x <= max_width:
ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
if text_size.x + ellipsis_size.x <= max_width:
return line + ellipsis
# If line + ellipsis doesn't fit, need to truncate
# Fall through to binary search below
left, right = 0, len(line)
while left < right:
mid = (left + right) // 2
candidate = line[:mid] + ellipsis
candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
if candidate_size.x <= max_width:
left = mid + 1
else:
right = mid
return line[:left - 1] + ellipsis if left > 0 else ellipsis
def get_content_height(self, max_width: int) -> float:
"""
Returns the height needed for text at given max_width.
Similar to HtmlRenderer.get_total_height().
"""
# Use max_width if provided, otherwise use self._max_width or a default
width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
self._update_text_cache(width)
if self._cached_total_height is not None:
return self._cached_total_height
return 0.0
def _render(self, _):
"""Render the label."""
if self._rect.width <= 0 or self._rect.height <= 0:
return
# Determine available width
available_width = self._rect.width
if self._max_width is not None:
available_width = min(available_width, self._max_width)
# Update text cache
self._update_text_cache(int(available_width))
if not self._cached_wrapped_lines:
return
# Calculate which lines fit in the available height
visible_lines: list[str] = []
visible_sizes: list[rl.Vector2] = []
visible_emojis: list[list[tuple[int, int, str]]] = []
current_height = 0.0
broke_early = False
for line, size, emojis in zip(
self._cached_wrapped_lines,
self._cached_line_sizes,
self._cached_line_emojis,
strict=True):
# Calculate height needed for this line
# Each line contributes its height * line_height (matching Label's behavior)
line_height_needed = size.y * self._line_height
# Check if this line fits
if current_height + line_height_needed > self._rect.height:
# This line doesn't fit
if len(visible_lines) == 0:
# First line doesn't fit by height - still show it (will be clipped by scissor if needed)
# Continue to add this line below
pass
else:
# We have visible lines and this one doesn't fit - mark that we broke early
broke_early = True
break
visible_lines.append(line)
visible_sizes.append(size)
visible_emojis.append(emojis)
current_height += line_height_needed
# If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
if broke_early and len(visible_lines) > 0 and self._elide:
content_width = int(available_width - (self._text_padding * 2))
if content_width <= 0:
content_width = 1
last_line_idx = len(visible_lines) - 1
last_line = visible_lines[last_line_idx]
# Force elide the last line to show "..." even if it fits in width (to indicate more content)
elided = self._elide_line(last_line, content_width, force=True)
visible_lines[last_line_idx] = elided
visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
if not visible_lines:
return
# Calculate total visible text block height
# First line is not changed by line_height scaling
total_visible_height = 0.0
for idx, size in enumerate(visible_sizes):
if idx == 0:
total_visible_height += size.y
else:
total_visible_height += size.y * self._line_height
# Calculate vertical alignment offset
if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
start_y = self._rect.y
elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
start_y = self._rect.y + self._rect.height - total_visible_height
else: # TEXT_ALIGN_MIDDLE
start_y = self._rect.y + (self._rect.height - total_visible_height) / 2
# Only scissor when we know there is a single scrolling line
# Pad a little since descenders like g or j may overflow below rect from font_scale
if self._needs_scroll:
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y - self._font_size / 2), int(self._rect.width), int(self._rect.height + self._font_size))
# Render each line
current_y = start_y
for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
if self._needs_scroll:
if self._scroll_state == ScrollState.STARTING:
if self._scroll_pause_t is None:
self._scroll_pause_t = rl.get_time() + 2.0
if rl.get_time() >= self._scroll_pause_t:
self._scroll_state = ScrollState.SCROLLING
self._scroll_pause_t = None
elif self._scroll_state == ScrollState.SCROLLING:
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
# don't fully hide
if self._scroll_offset <= -size.x - self._rect.width / 3:
self._scroll_offset = 0
self._scroll_state = ScrollState.STARTING
self._scroll_pause_t = None
else:
self.reset_scroll()
self._render_line(line, size, emojis, current_y)
# Draw 2nd instance for scrolling
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
text2_scroll_offset = size.x + self._rect.width / 3
self._render_line(line, size, emojis, current_y, text2_scroll_offset)
# Move to next line (if not last line)
if idx < len(visible_lines) - 1:
# Use current line's height * line_height for spacing to next line
current_y += size.y * self._line_height
if self._needs_scroll:
# draw black fade on left and right
fade_width = 20
rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - fade_width), int(self._rect.y), fade_width, int(self._rect.height), rl.BLANK, rl.BLACK)
if self._scroll_state != ScrollState.STARTING:
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), fade_width, int(self._rect.height), rl.BLACK, rl.BLANK)
rl.end_scissor_mode()
def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
# Calculate horizontal position
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
line_x = self._rect.x + self._text_padding
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
line_x = self._rect.x + (self._rect.width - size.x) / 2
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
line_x = self._rect.x + self._rect.width - size.x - self._text_padding
else:
line_x = self._rect.x + self._text_padding
line_x += self._scroll_offset + x_offset
# Render line with emojis
line_pos = rl.Vector2(line_x, current_y)
prev_index = 0
for start, end, emoji in emojis:
# Draw text before emoji
text_before = line[prev_index:start]
if text_before:
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
line_pos.x += width_before.x
# Draw emoji
tex = emoji_tex(emoji)
emoji_scale = self._font_size / tex.height * FONT_SCALE
rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
# Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
# Draw remaining text after last emoji
text_after = line[prev_index:]
if text_after:
rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
+760
View File
@@ -0,0 +1,760 @@
import os
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.widgets import Widget
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
RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80
BUTTON_WIDTH = 250
BUTTON_HEIGHT = 100
BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
TEXT_PADDING = 20
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
# Abstract base class for right-side items
class ItemAction(Widget, ABC):
def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled
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,
callback: Callable[[bool], None] | None = None):
super().__init__(width, enabled)
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)
self.toggle.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle) -> bool:
self.toggle.set_enabled(self.enabled)
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.toggle.set_state(state)
def get_state(self) -> bool:
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, tr("Error"))
@property
def value(self):
return _resolve_value(self._value_source, "")
def _render(self, rect: rl.Rectangle) -> bool:
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):
def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
self._text_source = text
self.color = color
self._font = gui_app.font(FontWeight.NORMAL)
initial_text = _resolve_value(text, "")
text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x
super().__init__(int(text_width + TEXT_PADDING), enabled)
@property
def text(self):
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:
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 set_text(self, text: str | Callable[[], str]):
self._text_source = text
class DualButtonAction(ItemAction):
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_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 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
button_y = rect.y + (rect.height - button_height) / 2
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)
# 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
# Render buttons
self.left_button.render(left_rect)
self.right_button.render(right_rect)
class MultipleButtonAction(ItemAction):
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 set_selected_button(self, index: int):
if 0 <= index < len(self.buttons):
self.selected_button = index
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_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
if is_selected:
bg_color = rl.Color(51, 171, 76, 255) # Green
elif is_pressed:
bg_color = rl.Color(74, 74, 74, 255) # Dark gray
else:
bg_color = rl.Color(57, 57, 57, 255) # Gray
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
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)
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 | 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.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._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_description: str | None = self.description
@property
def enabled(self) -> bool:
if self.action_item:
return self.action_item.enabled
return True
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)
if self.action_item:
self.action_item.set_touch_valid_callback(touch_callback)
def set_parent_rect(self, parent_rect: rl.Rectangle):
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self.is_visible:
return
# Check not in action rect
if self.action_item:
action_rect = self.get_right_item_rect(self._rect)
if rl.check_collision_point_rec(mouse_pos, action_rect):
# Click was on right item, don't toggle description
return
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
# Don't draw items that are not in parent's viewport
if ((self._rect.y + self.rect.height) <= self._parent_rect.y or
self._rect.y >= (self._parent_rect.y + self._parent_rect.height)):
return
content_x = self._rect.x + ITEM_PADDING
text_x = content_x
color = ITEM_TEXT_COLOR if self.enabled else ITEM_TEXT_VALUE_COLOR
icon_tint = rl.WHITE if self.enabled else ITEM_TEXT_VALUE_COLOR
# Only draw title and icon for items that have them
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.height) // 2), icon_tint)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, color)
# Draw description if visible
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:
right_rect = self.get_right_item_rect(self._rect)
right_rect.y = self._rect.y
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if self.callback:
self.callback()
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
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.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 | Callable[[], str], callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback)
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, callback=callback)
return ListItem(title=title, description=description, action_item=action, icon=icon)
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 | 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=ITEM_TEXT_VALUE_COLOR, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
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 | 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)
# Copyright (c) 2019, Rick Lan
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, and/or sublicense,
# for non-commercial purposes only, subject to the following conditions:
#
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# - Commercial use (e.g. use in a product, service, or activity intended to
# generate revenue) is prohibited without explicit written permission from
# the copyright holder.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from abc import ABC, abstractmethod
class BaseSpinBoxAction(ItemAction, ABC):
def __init__(self, callback: Callable | None, enabled: bool | Callable[[], bool], width: int):
super().__init__(width=width, enabled=enabled)
self._callback = callback
icon_size = 60
self._minus_icon = gui_app.texture("icons/minus.png", icon_size, icon_size)
self._plus_icon = gui_app.texture("icons/plus.png", icon_size, icon_size)
self._minus_button = Button("", self._on_minus, icon=self._minus_icon, button_style=ButtonStyle.LIST_ACTION, multi_touch=True)
self._plus_button = Button("", self._on_plus, icon=self._plus_icon, button_style=ButtonStyle.LIST_ACTION, multi_touch=True)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self._minus_button.set_touch_valid_callback(touch_callback)
self._plus_button.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle) -> bool:
is_enabled = _resolve_value(self._enabled_source, False)
button_width = 110
button_height = BUTTON_HEIGHT
spacing = 10
button_y = rect.y + (rect.height - button_height) / 2
minus_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
plus_rect = rl.Rectangle(rect.x + rect.width - button_width, button_y, button_width, button_height)
label_x = rect.x + button_width + spacing
label_width = (plus_rect.x) - (label_x) - spacing
self._minus_button.set_enabled(is_enabled and self._get_minus_enabled())
self._plus_button.set_enabled(is_enabled and self._get_plus_enabled())
self._minus_button.render(minus_rect)
self._plus_button.render(plus_rect)
if label_width > 0:
label_rect = rl.Rectangle(label_x, rect.y, label_width, rect.height)
display_text = self._get_display_text()
color = ITEM_TEXT_VALUE_COLOR if is_enabled else ITEM_DESC_TEXT_COLOR
gui_label(label_rect, display_text, font_size=ITEM_TEXT_FONT_SIZE, color=color,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
return False
@abstractmethod
def _on_minus(self):
"""Called when the minus button is pressed."""
pass
@abstractmethod
def _on_plus(self):
"""Called when the plus button is pressed."""
pass
@abstractmethod
def _get_minus_enabled(self) -> bool:
"""Return True if the minus button should be enabled."""
pass
@abstractmethod
def _get_plus_enabled(self) -> bool:
"""Return True if the plus button should be enabled."""
pass
@abstractmethod
def _get_display_text(self) -> str:
"""Return the string to display in the center."""
pass
class SpinBoxAction(BaseSpinBoxAction):
def __init__(self, initial_value: int, min_val: int, max_val: int, step: int = 1,
suffix: str = "", special_value_text: str | None = None,
callback: Callable[[int], None] | None = None, enabled: bool | Callable[[], bool] = True,
width: int = 320):
super().__init__(callback, enabled, width)
self._value = initial_value
self._min_val = min_val
self._max_val = max_val
self._step = step
self._suffix = suffix
self._special_value_text = special_value_text
def set_value(self, value: int):
self._value = max(self._min_val, min(self._max_val, value))
def get_value(self) -> int:
return self._value
def _on_minus(self):
new_val = max(self._min_val, self._value - self._step)
if new_val != self._value:
self._value = new_val
if self._callback:
self._callback(self._value)
def _on_plus(self):
new_val = min(self._max_val, self._value + self._step)
if new_val != self._value:
self._value = new_val
if self._callback:
self._callback(self._value)
def _get_minus_enabled(self) -> bool:
return self._value > self._min_val
def _get_plus_enabled(self) -> bool:
return self._value < self._max_val
def _get_display_text(self) -> str:
if self._special_value_text and self._value == self._min_val:
return self._special_value_text
return f"{self._value}{self._suffix}"
class DoubleSpinBoxAction(BaseSpinBoxAction):
def __init__(self, initial_value: float, min_val: float, max_val: float, step: float = 0.1,
decimals: int = 1, suffix: str = "", special_value_text: str | None = None, # <-- 1. This is correct
callback: Callable[[float], None] | None = None, enabled: bool | Callable[[], bool] = True,
width: int = 320):
super().__init__(callback, enabled, width)
self._value = initial_value
self._min_val = min_val
self._max_val = max_val
self._step = step
self._decimals = decimals
self._suffix = suffix
self._special_value_text = special_value_text # <-- 2. Store the variable
def set_value(self, value: float):
self._value = max(self._min_val, min(self._max_val, value))
def get_value(self) -> float:
return self._value
def _on_minus(self):
new_val = max(self._min_val, self._value - self._step)
if new_val < self._value:
self._value = new_val
if self._callback:
self._callback(self._value)
def _on_plus(self):
new_val = min(self._max_val, self._value + self._step)
if new_val > self._value:
self._value = new_val
if self._callback:
self._callback(self._value)
def _get_minus_enabled(self) -> bool:
return self._value > self._min_val
def _get_plus_enabled(self) -> bool:
return self._value < self._max_val
def _get_display_text(self) -> str:
is_min_val = abs(self._value - self._min_val) < 1e-9
if self._special_value_text and is_min_val:
return self._special_value_text
return f"{self._value:.{self._decimals}f}{self._suffix}"
class TextSpinBoxAction(BaseSpinBoxAction):
def __init__(self, options: list[str], initial_index: int = 0,
callback: Callable[[int], None] | None = None, enabled: bool | Callable[[], bool] = True,
width: int = 320):
super().__init__(callback, enabled, width)
self._options = options if options else [""]
self._current_index = max(0, min(len(self._options) - 1, initial_index))
self._initial_index = initial_index
def set_index(self, index: int):
self._current_index = max(0, min(len(self._options) - 1, index))
def get_index(self) -> int:
return self._current_index
def _on_minus(self):
new_idx = max(0, self._current_index - 1)
if new_idx != self._current_index:
self._current_index = new_idx
if self._callback:
self._callback(self._current_index)
def _on_plus(self):
new_idx = min(len(self._options) - 1, self._current_index + 1)
if new_idx != self._current_index:
self._current_index = new_idx
if self._callback:
self._callback(self._current_index)
def _get_minus_enabled(self) -> bool:
return self._current_index > 0
def _get_plus_enabled(self) -> bool:
return self._current_index < len(self._options) - 1
def _get_display_text(self) -> str:
return self._options[self._current_index]
def spin_button_item(title: str | Callable[[], str], callback: Callable[[int], None] | None,
initial_value: int, min_val: int, max_val: int, step: int = 1,
suffix: str = "", special_value_text: str | None = None,
description: str | Callable[[], str] | None = None,
icon: str = "", enabled: bool | Callable[[], bool] = True,
width: int = 500) -> ListItem:
"""
Creates a ListItem with a spinbox-style control (minus, value, plus).
:param title: The main title of the list item.
:param callback: Function to call with the new integer value when it changes.
:param initial_value: The starting value.
:param min_val: The minimum allowed value.
:param max_val: The maximum allowed value.
:param step: The increment/decrement amount on each button press.
:param suffix: A string to append to the value (e.g., " s").
:param special_value_text: Text to display when the value is at min_val (e.g., "Auto").
:param description: Optional description text shown when the item is expanded.
:param icon: Optional icon for the list item.
:param enabled: Whether the control is enabled.
:return: A ListItem widget.
"""
action = SpinBoxAction(initial_value=initial_value, min_val=min_val, max_val=max_val, step=step,
suffix=suffix, special_value_text=special_value_text,
callback=callback, enabled=enabled, width=width)
return ListItem(title=title, description=description, action_item=action, icon=icon)
def double_spin_button_item(title: str | Callable[[], str], callback: Callable[[float], None] | None,
initial_value: float, min_val: float, max_val: float, step: float = 0.1,
decimals: int = 1, suffix: str = "", special_value_text: str | None = None,
description: str | Callable[[], str] | None = None,
icon: str = "", enabled: bool | Callable[[], bool] = True,
width: int = 500) -> ListItem:
"""
Creates a ListItem with a spinbox-style control for float values.
:param decimals: Number of decimal places to display.
:return: A ListItem widget.
"""
action = DoubleSpinBoxAction(initial_value=initial_value, min_val=min_val, max_val=max_val, step=step,
decimals=decimals, suffix=suffix, special_value_text=special_value_text,
callback=callback, enabled=enabled, width=width)
return ListItem(title=title, description=description, action_item=action, icon=icon)
def text_spin_button_item(title: str | Callable[[], str], callback: Callable[[int], None] | None,
options: list[str], initial_index: int = 0,
description: str | Callable[[], str] | None = None,
icon: str = "", enabled: bool | Callable[[], bool] = True,
width: int = 500) -> ListItem:
"""
Creates a ListItem with a spinbox control for a list of text options.
:param options: A list of strings to cycle through (e.g., ['Low', 'Mid', 'High']).
:param initial_index: The starting index in the options list.
:param callback: Function to call with the new *index* (int) when it changes.
:return: A ListItem widget.
"""
action = TextSpinBoxAction(options=options, initial_index=initial_index,
callback=callback, enabled=enabled, width=width)
return ListItem(title=title, description=description, action_item=action, icon=icon)
+391
View File
@@ -0,0 +1,391 @@
from enum import IntEnum
import pyray as rl
import numpy as np
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
CHAR_FONT_SIZE = 42
CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2
SELECTED_CHAR_FONT_SIZE = 128
CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this
NUMBER_LAYER_SWITCH_FONT_SIZE = 24
KEYBOARD_COLUMN_PADDING = 33
KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in
KEY_TOUCH_AREA_OFFSET = 10 # px
KEY_DRAG_HYSTERESIS = 5 # px
KEY_MIN_ANIMATION_TIME = 0.075 # s
DEBUG = False
ANIMATION_SCALE = 0.65
def zip_repeat(a, b):
la, lb = len(a), len(b)
for i in range(max(la, lb)):
yield (a[i] if i < la else a[-1],
b[i] if i < lb else b[-1])
def fast_euclidean_distance(dx, dy):
# https://en.wikibooks.org/wiki/Algorithms/Distance_approximations
max_d, min_d = abs(dx), abs(dy)
if max_d < min_d:
max_d, min_d = min_d, max_d
return 0.941246 * max_d + 0.41 * min_d
class Key(Widget):
def __init__(self, char: str):
super().__init__()
self.char = char
self._font = gui_app.font(FontWeight.SEMI_BOLD)
self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._color = rl.Color(255, 255, 255, 255)
self._position_initialized = False
self.original_position = rl.Vector2(0, 0)
def set_position(self, x: float, y: float, smooth: bool = True):
# TODO: swipe up from NavWidget has the keys lag behind other elements a bit
if not self._position_initialized:
self._x_filter.x = x
self._y_filter.x = y
# keep track of original position so dragging around feels consistent. also move touch area down a bit
self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET)
self._position_initialized = True
if not smooth:
self._x_filter.x = x
self._y_filter.x = y
self._rect.x = self._x_filter.update(x)
self._rect.y = self._y_filter.update(y)
def set_alpha(self, alpha: float):
self._alpha_filter.update(alpha)
def get_position(self) -> tuple[float, float]:
return self._rect.x, self._rect.y
def _update_state(self):
self._color.a = min(int(255 * self._alpha_filter.x), 255)
def _render(self, _):
# center char at rect position
text_size = measure_text_cached(self._font, self.char, self._get_font_size())
x = self._rect.x + self._rect.width / 2 - text_size.x / 2
y = self._rect.y + self._rect.height / 2 - text_size.y / 2
rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color)
if DEBUG:
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
def set_font_size(self, size: float):
self._size_filter.update(size)
def _get_font_size(self) -> int:
return int(round(self._size_filter.x))
class SmallKey(Key):
def __init__(self, chars: str):
super().__init__(chars)
self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
def set_font_size(self, size: float):
self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE))
class IconKey(Key):
def __init__(self, icon: str, vertical_align: str = "center", char: str = ""):
super().__init__(char)
self._icon = gui_app.texture(icon, 38, 38)
self._vertical_align = vertical_align
def set_icon(self, icon: str):
self._icon = gui_app.texture(icon, 38, 38)
def _render(self, _):
scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5])
if self._vertical_align == "center":
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2,
self._rect.y + (self._rect.height - self._icon.height * scale) / 2,
self._icon.width * scale, self._icon.height * scale)
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
elif self._vertical_align == "bottom":
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y,
self._icon.width * scale, self._icon.height * scale)
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
if DEBUG:
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
class CapsState(IntEnum):
LOWER = 0
UPPER = 1
LOCK = 2
class MiciKeyboard(Widget):
def __init__(self):
super().__init__()
lower_chars = [
"qwertyuiop",
"asdfghjkl",
"zxcvbnm",
]
upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars]
special_chars = [
"1234567890",
"-/:;()$&@\"",
"~.,?!'#%",
]
super_special_chars = [
"1234567890",
"`[]{}^*+=_",
"\\|<>¥€£•",
]
self._lower_keys = [[Key(char) for char in row] for row in lower_chars]
self._upper_keys = [[Key(char) for char in row] for row in upper_chars]
self._special_keys = [[Key(char) for char in row] for row in special_chars]
self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
# control keys
self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom")
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png")
# these two are in different places on some layouts
self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123")
self._abc_key = SmallKey("abc")
self._super_special_key = SmallKey("#+=")
# insert control keys
for keys in (self._lower_keys, self._upper_keys):
keys[2].insert(0, self._caps_key)
keys[2].append(self._123_key)
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
keys[1].append(self._space_key)
for keys in (self._special_keys, self._super_special_keys):
keys[2].append(self._abc_key)
self._special_keys[2].insert(0, self._super_special_key)
self._super_special_keys[2].insert(0, self._123_key2)
# set initial keys
self._current_keys: list[list[Key]] = []
self._set_keys(self._lower_keys)
self._caps_state = CapsState.LOWER
self._initialized = False
self._load_images()
self._closest_key: tuple[Key | None, float] = None, float('inf')
self._selected_key_t: float | None = None # time key was initially selected
self._unselect_key_t: float | None = None # time to unselect key after release
self._dragging_on_keyboard = False
self._text: str = ""
self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._selected_key_filter = FirstOrderFilter(0.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
def get_candidate_character(self) -> str:
# return str of character about to be added to text
key = self._closest_key[0]
return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else ""
def get_keyboard_height(self) -> int:
return int(self._txt_bg.height)
def _load_images(self):
self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False)
def _set_keys(self, keys: list[list[Key]]):
# inherit previous keys' positions to fix switching animation
for current_row, row in zip(self._current_keys, keys, strict=False):
# not all layouts have the same number of keys
for current_key, key in zip_repeat(current_row, row):
current_pos = current_key.get_position()
key.set_position(current_pos[0], current_pos[1], smooth=False)
self._current_keys = keys
def set_text(self, text: str):
self._text = text
def text(self) -> str:
return self._text
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
keyboard_pos_y = self._rect.y + self._rect.height - self._txt_bg.height
if mouse_event.left_pressed:
if mouse_event.pos.y > keyboard_pos_y:
self._dragging_on_keyboard = True
elif mouse_event.left_released:
self._dragging_on_keyboard = False
if mouse_event.left_down and self._dragging_on_keyboard:
self._closest_key = self._get_closest_key()
if self._selected_key_t is None:
self._selected_key_t = rl.get_time()
# unselect key temporarily if mouse goes above keyboard
if mouse_event.pos.y <= keyboard_pos_y:
self._closest_key = (None, float('inf'))
if DEBUG:
print('HANDLE MOUSE EVENT', mouse_event, self._closest_key[0].char if self._closest_key[0] else 'None')
def _get_closest_key(self) -> tuple[Key | None, float]:
closest_key: tuple[Key | None, float] = (None, float('inf'))
for row in self._current_keys:
for key in row:
mouse_pos = gui_app.last_mouse_event.pos
# approximate distance for comparison is accurate enough
dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y)
if dist < closest_key[1]:
if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS:
closest_key = (key, dist)
return closest_key
def _set_uppercase(self, cycle: bool):
self._set_keys(self._upper_keys if cycle else self._lower_keys)
if not cycle:
self._caps_state = CapsState.LOWER
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png")
else:
if self._caps_state == CapsState.LOWER:
self._caps_state = CapsState.UPPER
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png")
elif self._caps_state == CapsState.UPPER:
self._caps_state = CapsState.LOCK
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png")
else:
self._set_uppercase(False)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._closest_key[0] is not None:
if self._closest_key[0] == self._caps_key:
self._set_uppercase(True)
elif self._closest_key[0] in (self._123_key, self._123_key2):
self._set_keys(self._special_keys)
elif self._closest_key[0] == self._abc_key:
self._set_uppercase(False)
elif self._closest_key[0] == self._super_special_key:
self._set_keys(self._super_special_keys)
else:
self._text += self._closest_key[0].char
# Reset caps state
if self._caps_state == CapsState.UPPER:
self._set_uppercase(False)
# ensure minimum selected animation time
key_selected_dt = rl.get_time() - (self._selected_key_t or 0)
cur_t = rl.get_time()
self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t
def backspace(self):
if self._text:
self._text = self._text[:-1]
def space(self):
self._text += ' '
def _update_state(self):
# update selected key filter
self._selected_key_filter.update(self._closest_key[0] is not None)
# unselect key after animation plays
if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t:
self._closest_key = (None, float('inf'))
self._unselect_key_t = None
self._selected_key_t = None
def _lay_out_keys(self, bg_x, bg_y, keys: list[list[Key]]):
key_rect = rl.Rectangle(bg_x, bg_y, self._txt_bg.width, self._txt_bg.height)
for row_idx, row in enumerate(keys):
padding = KEYBOARD_ROW_PADDING[row_idx]
step_y = (key_rect.height - 2 * KEYBOARD_COLUMN_PADDING) / (len(keys) - 1)
for key_idx, key in enumerate(row):
key_x = key_rect.x + padding + key_idx * ((key_rect.width - 2 * padding) / (len(row) - 1))
key_y = key_rect.y + KEYBOARD_COLUMN_PADDING + row_idx * step_y
if self._closest_key[0] is None:
key.set_alpha(1.0)
key.set_font_size(CHAR_FONT_SIZE)
elif key == self._closest_key[0]:
# push key up with a max and inward so user can see key easier
key_y = max(key_y - 120, 40)
key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100])
key.set_alpha(1.0)
key.set_font_size(SELECTED_CHAR_FONT_SIZE)
# draw black circle behind selected key
circle_alpha = int(self._selected_key_filter.x * 225)
rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2),
SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK)
else:
# move other keys away from selected key a bit
dx = key.original_position.x - self._closest_key[0].original_position.x
dy = key.original_position.y - self._closest_key[0].original_position.y
distance_from_selected_key = fast_euclidean_distance(dx, dy)
inv = 1 / (distance_from_selected_key or 1.0)
ux = dx * inv
uy = dy * inv
# NOTE: hardcode to 20 to get entire keyboard to move
push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0])
key_x += ux * push_pixels
key_y += uy * push_pixels
# TODO: slow enough to use an approximation or nah? also caching might work
font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE])
key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35])
key.set_alpha(key_alpha)
key.set_font_size(font_size)
# TODO: I like the push amount, so we should clip the pos inside the keyboard rect
key.set_position(key_x, key_y)
def _render(self, _):
# draw bg
bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2
bg_y = self._rect.y + self._rect.height - self._txt_bg.height
scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0)
src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height)
dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y,
self._txt_bg.width * scale, self._txt_bg.height)
rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE)
# draw keys
if not self._initialized:
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
self._lay_out_keys(bg_x, bg_y, keys)
self._initialized = True
self._lay_out_keys(bg_x, bg_y, self._current_keys)
for row in self._current_keys:
for key in row:
key.render()
+494
View File
@@ -0,0 +1,494 @@
from enum import IntEnum
from functools import partial
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, 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 gui_label
from openpilot.system.ui.widgets.scroller_tici 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
MAX_PASSWORD_LENGTH = 64
ITEM_HEIGHT = 160
ICON_SIZE = 50
STRENGTH_ICONS = [
"icons/wifi_strength_low.png",
"icons/wifi_strength_medium.png",
"icons/wifi_strength_high.png",
"icons/wifi_strength_full.png",
]
class PanelType(IntEnum):
WIFI = 0
ADVANCED = 1
class UIState(IntEnum):
IDLE = 0
CONNECTING = 1
NEEDS_AUTH = 2
SHOW_FORGET_CONFIRM = 3
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)
# dp - retain tethering after reboot
# same logic as _toggle_tethering()
if self._params.get_bool("dp_dev_tethering"):
self._tethering_action.set_enabled(False)
self._wifi_metered_action.set_enabled(False)
self._wifi_manager.set_tethering_active(True)
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._params.put_bool_nonblocking("dp_dev_tethering", checked)
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.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
self.btn_width: int = 200
self.scroll_panel = GuiScrollPanel()
self.keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
self._load_icons()
self._networks: list[Network] = []
self._networks_buttons: dict[str, Button] = {}
self._forget_networks_buttons: dict[str, Button] = {}
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)
def hide_event(self):
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 _update_state(self):
self._wifi_manager.process_callbacks()
def _render(self, rect: rl.Rectangle):
if not self._networks:
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(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:
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)
def _on_password_entered(self, network: Network, result: int):
if result == 1:
password = self.keyboard.text
self.keyboard.clear()
if len(password) >= MIN_PASSWORD_LENGTH:
self.connect_to_network(network, password)
elif result == 0:
self.state = UIState.IDLE
def on_forgot_confirm_finished(self, network, result: int):
if result == 1:
self.forget_network(network)
elif result == 0:
self.state = UIState.IDLE
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.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
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)
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):
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)
security_icon_rect = rl.Rectangle(signal_icon_rect.x - spacing - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
status_text = ""
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 = 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 = tr("FORGETTING...")
elif network.security_type == SecurityType.UNSUPPORTED:
self._networks_buttons[network.ssid].set_enabled(False)
else:
self._networks_buttons[network.ssid].set_enabled(True)
self._networks_buttons[network.ssid].render(ssid_rect)
if status_text:
status_text_rect = rl.Rectangle(security_icon_rect.x - 410, rect.y, 410, ITEM_HEIGHT)
gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
else:
# If the network is saved, show the "Forget" button
if network.is_saved:
forget_btn_rect = rl.Rectangle(
security_icon_rect.x - self.btn_width - spacing,
rect.y + (ITEM_HEIGHT - 80) / 2,
self.btn_width,
80,
)
self._forget_networks_buttons[network.ssid].render(forget_btn_rect)
self._draw_status_icon(security_icon_rect, network)
self._draw_signal_strength_icon(signal_icon_rect, network)
def _networks_buttons_callback(self, 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):
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"""
icon_file = None
if network.is_connected and self.state != UIState.CONNECTING:
icon_file = "icons/checkmark.png"
elif network.security_type == SecurityType.UNSUPPORTED:
icon_file = "icons/circled_slash.png"
elif network.security_type != SecurityType.OPEN:
icon_file = "icons/lock_closed.png"
if not icon_file:
return
texture = gui_app.texture(icon_file, ICON_SIZE, ICON_SIZE)
icon_rect = rl.Vector2(rect.x, rect.y + (ICON_SIZE - texture.height) / 2)
rl.draw_texture_v(texture, icon_rect, rl.WHITE)
def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: Network):
"""Draw the Wi-Fi signal strength icon based on network's signal strength"""
strength_level = max(0, min(3, round(network.strength / 33.0)))
rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE)
def connect_to_network(self, network: Network, password=''):
self.state = UIState.CONNECTING
self._state_network = network
if network.is_saved and not password:
self._wifi_manager.activate_connection(network.ssid)
else:
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)
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=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)
if network:
self.state = UIState.NEEDS_AUTH
self._state_network = network
self._password_retry = True
def _on_activated(self):
if self.state == UIState.CONNECTING:
self.state = UIState.IDLE
def _on_forgotten(self):
if self.state == UIState.FORGETTING:
self.state = UIState.IDLE
def _on_disconnected(self):
if self.state == UIState.CONNECTING:
self.state = UIState.IDLE
def main():
gui_app.init_window("Wi-Fi Manager")
wifi_ui = WifiManagerUI(WifiManager())
for _ in gui_app.render():
wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100))
gui_app.close()
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
import pyray as rl
from openpilot.system.ui.lib.application import FontWeight
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_tici import Scroller
# Constants
MARGIN = 50
TITLE_FONT_SIZE = 70
ITEM_HEIGHT = 135
BUTTON_SPACING = 50
BUTTON_HEIGHT = 160
ITEM_SPACING = 50
LIST_ITEM_SPACING = 25
class MultiOptionDialog(Widget):
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._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)
rl.draw_rectangle_rounded(dialog_rect, 0.02, 20, rl.Color(30, 30, 30, 255))
content_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - 2 * MARGIN)
gui_label(rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, TITLE_FONT_SIZE), self.title, 70, font_weight=FontWeight.BOLD)
# 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
options_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h)
# Update button styles and set width based on selection
for i, option in enumerate(self.options):
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))
self.scroller.render(options_rect)
# Buttons
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
button_w = (content_rect.width - BUTTON_SPACING) / 2
cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT)
self.cancel_button.render(cancel_rect)
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 self._result
+264
View File
@@ -0,0 +1,264 @@
import pyray as rl
import numpy as np
from collections.abc import Callable
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
from openpilot.system.ui.widgets import Widget
ITEM_SPACING = 20
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
ANIMATION_SCALE = 0.6
MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
DO_ZOOM = False
DO_JELLO = False
SCROLL_BAR = False
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING,
line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING):
super().__init__()
self._items: list[Widget] = []
self._horizontal = horizontal
self._snap_items = snap_items
self._spacing = spacing
self._line_separator = LineSeparator() if line_separator else None
self._pad_start = pad_start
self._pad_end = pad_end
self._reset_scroll_at_show = True
self._scrolling_to: float | None = None
self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
self._zoom_out_t: float = 0.0
# layout state
self._visible_items: list[Widget] = []
self._content_size: float = 0.0
self._scroll_offset: float = 0.0
self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
# when not pressed, snap to closest item to be center
self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
self._scroll_enabled: bool | Callable[[], bool] = True
self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80)
for item in items:
self.add_widget(item)
def set_reset_scroll_at_show(self, scroll: bool):
self._reset_scroll_at_show = scroll
def scroll_to(self, pos: float, smooth: bool = False):
# already there
if abs(pos) < 1:
return
# FIXME: the padding correction doesn't seem correct
scroll_offset = self.scroll_panel.get_offset() - pos
if smooth:
self._scrolling_to = scroll_offset
else:
self.scroll_panel.set_offset(scroll_offset)
@property
def is_auto_scrolling(self) -> bool:
return self._scrolling_to is not None
def add_widget(self, item: Widget) -> None:
self._items.append(item)
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
"""Set whether scrolling is enabled (does not affect widget enabled state)."""
self._scroll_enabled = enabled
def _update_state(self):
if DO_ZOOM:
if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY:
self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME
self._zoom_filter.update(0.85)
else:
if self._zoom_out_t is not None:
if rl.get_time() > self._zoom_out_t:
self._zoom_filter.update(1.0)
else:
self._zoom_filter.update(0.85)
# Cancel auto-scroll if user starts manually scrolling
if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL):
self._scrolling_to = None
if self._scrolling_to is not None:
self._scroll_filter.update(self._scrolling_to)
self.scroll_panel.set_offset(self._scroll_filter.x)
if abs(self._scroll_filter.x - self._scrolling_to) < 1:
self.scroll_panel.set_offset(self._scrolling_to)
self._scrolling_to = None
else:
# keep current scroll position up to date
self._scroll_filter.x = self.scroll_panel.get_offset()
def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float:
scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled
self.scroll_panel.set_enabled(scroll_enabled and self.enabled)
self.scroll_panel.update(self._rect, content_size)
if not self._snap_items:
return round(self.scroll_panel.get_offset())
# Snap closest item to center
center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2
closest_delta_pos = float('inf')
scroll_snap_idx: int | None = None
for idx, item in enumerate(visible_items):
if self._horizontal:
delta_pos = (item.rect.x + item.rect.width / 2) - center_pos
else:
delta_pos = (item.rect.y + item.rect.height / 2) - center_pos
if abs(delta_pos) < abs(closest_delta_pos):
closest_delta_pos = delta_pos
scroll_snap_idx = idx
if scroll_snap_idx is not None:
snap_item = visible_items[scroll_snap_idx]
if self.is_pressed:
# no snapping until released
self._scroll_snap_filter.x = 0
else:
# TODO: this doesn't handle two small buttons at the edges well
if self._horizontal:
snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10
snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10)
else:
snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10
snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
snap_delta_pos = max(snap_delta_pos, (self._rect.height - self.scroll_panel.get_offset() - content_size) / 10)
self._scroll_snap_filter.update(snap_delta_pos)
self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x)
return self.scroll_panel.get_offset()
def _layout(self):
self._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(self._visible_items)
for i in range(1, len(self._visible_items)):
self._visible_items.insert(l - i, self._line_separator)
self._content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in self._visible_items)
self._content_size += self._spacing * (len(self._visible_items) - 1)
self._content_size += self._pad_start + self._pad_end
self._scroll_offset = self._get_scroll(self._visible_items, self._content_size)
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
self._item_pos_filter.update(self._scroll_offset)
cur_pos = 0
for idx, item in enumerate(self._visible_items):
spacing = self._spacing if (idx > 0) else self._pad_start
# Nicely lay out items horizontally/vertically
if self._horizontal:
x = self._rect.x + cur_pos + spacing
y = self._rect.y + (self._rect.height - item.rect.height) / 2
cur_pos += item.rect.width + spacing
else:
x = self._rect.x + (self._rect.width - item.rect.width) / 2
y = self._rect.y + cur_pos + spacing
cur_pos += item.rect.height + spacing
# Consider scroll
if self._horizontal:
x += self._scroll_offset
else:
y += self._scroll_offset
# Add some jello effect when scrolling
if DO_JELLO:
if self._horizontal:
cx = self._rect.x + self._rect.width / 2
jello_offset = self._scroll_offset - np.interp(x + item.rect.width / 2,
[self._rect.x, cx, self._rect.x + self._rect.width],
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
x -= np.clip(jello_offset, -20, 20)
else:
cy = self._rect.y + self._rect.height / 2
jello_offset = self._scroll_offset - np.interp(y + item.rect.height / 2,
[self._rect.y, cy, self._rect.y + self._rect.height],
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
y -= np.clip(jello_offset, -20, 20)
# Update item state
item.set_position(round(x), round(y)) # round to prevent jumping when settling
item.set_parent_rect(self._rect)
def _render(self, _):
for item in self._visible_items:
# Skip rendering if not in viewport
if not rl.check_collision_recs(item.rect, self._rect):
continue
# Scale each element around its own origin when scrolling
scale = self._zoom_filter.x
if scale != 1.0:
rl.rl_push_matrix()
rl.rl_scalef(scale, scale, 1.0)
rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale,
(1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0)
item.render()
rl.rl_pop_matrix()
else:
item.render()
# Draw scroll indicator
if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0:
_real_content_size = self._content_size - self._rect.height + self._txt_scroll_indicator.height
scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height
scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_scroll_indicator.height)
rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)
rl.end_scissor_mode()
def show_event(self):
super().show_event()
if self._reset_scroll_at_show:
self.scroll_panel.set_offset(0.0)
for item in self._items:
item.show_event()
def hide_event(self):
super().hide_event()
for item in self._items:
item.hide_event()
+90
View File
@@ -0,0 +1,90 @@
import pyray as rl
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.widgets import Widget
ITEM_SPACING = 40
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
self._line_separator = LineSeparator() if line_separator else None
self._pad_end = pad_end
self.scroll_panel = GuiScrollPanel()
for item in items:
self.add_widget(item)
def add_widget(self, item: Widget) -> None:
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.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))
cur_height = 0
for idx, item in enumerate(visible_items):
if not item.is_visible:
continue
# Nicely lay out items vertically
x = self._rect.x
y = self._rect.y + cur_height + self._spacing * (idx != 0)
cur_height += item.rect.height + self._spacing * (idx != 0)
# Consider scroll
y += scroll
# Update item state
item.set_position(x, y)
item.set_parent_rect(self._rect)
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()
+186
View File
@@ -0,0 +1,186 @@
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.common.filter_simple import FirstOrderFilter
class SmallSlider(Widget):
HORIZONTAL_PADDING = 8
CONFIRM_DELAY = 0.2
def __init__(self, title: str, confirm_callback: Callable | None = None):
# TODO: unify this with BigConfirmationDialogV2
super().__init__()
self._confirm_callback = confirm_callback
self._font = gui_app.font(FontWeight.DISPLAY)
self._load_assets()
self._drag_threshold = -self._rect.width // 2
# State
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._confirmed_time = 0.0
self._confirm_callback_called = False # we keep dialog open by default, only call once
self._start_x_circle = 0.0
self._scroll_x_circle = 0.0
self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._is_dragging_circle = False
self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.MEDIUM, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100)
self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100)
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
@property
def confirmed(self) -> bool:
return self._confirmed_time > 0.0
def reset(self):
# reset all slider state
self._is_dragging_circle = False
self._confirmed_time = 0.0
self._confirm_callback_called = False
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
@property
def slider_percentage(self):
activated_pos = -self._bg_txt.width + self._circle_bg_txt.width
return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0)
def _on_confirm(self):
if self._confirm_callback:
self._confirm_callback()
def _handle_mouse_event(self, mouse_event):
super()._handle_mouse_event(mouse_event)
if mouse_event.left_pressed:
# touch rect goes to the padding
circle_button_rect = rl.Rectangle(
self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2,
self._rect.y,
self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2,
self._rect.height,
)
if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
self._start_x_circle = mouse_event.pos.x
self._is_dragging_circle = True
elif mouse_event.left_released:
# swiped to left
if self._scroll_x_circle_filter.x < self._drag_threshold:
self._confirmed_time = rl.get_time()
self._is_dragging_circle = False
if self._is_dragging_circle:
self._scroll_x_circle = mouse_event.pos.x - self._start_x_circle
def _update_state(self):
super()._update_state()
# TODO: this math can probably be cleaned up to remove duplicate stuff
activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
if self._confirmed_time > 0:
# swiped left to confirm
self._scroll_x_circle_filter.update(activated_pos)
# activate once animation completes, small threshold for small floats
if self._scroll_x_circle_filter.x < (activated_pos + 1):
if not self._confirm_callback_called and (rl.get_time() - self._confirmed_time) >= self.CONFIRM_DELAY:
self._on_confirm()
self._confirm_callback_called = True
elif not self._is_dragging_circle:
# reset back to right
self._scroll_x_circle_filter.update(0)
else:
# not activated yet, keep movement 1:1
self._scroll_x_circle_filter.x = self._scroll_x_circle
def _render(self, _):
# TODO: iOS text shimmering animation
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
bg_txt_y = self._rect.y + (self._rect.height - self._bg_txt.height) / 2
rl.draw_texture_ex(self._bg_txt, rl.Vector2(bg_txt_x, bg_txt_y), 0.0, 1.0, white)
btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
if self._confirmed_time == 0.0 or self._scroll_x_circle > 0:
self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity_filter.x)))
label_rect = rl.Rectangle(
self._rect.x + 20,
self._rect.y,
self._rect.width - self._circle_bg_txt.width - 20 * 2.5,
self._rect.height,
)
self._label.render(label_rect)
# circle and arrow
rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white)
arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2
rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white)
class LargerSlider(SmallSlider):
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True):
self._green = green
super().__init__(title, confirm_callback=confirm_callback)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115)
circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle"
self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115)
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
class BigSlider(SmallSlider):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None):
self._icon = icon
super().__init__(title, confirm_callback=confirm_callback)
self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
line_height=0.875)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
self._circle_arrow_txt = self._icon
class RedBigSlider(BigSlider):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
self._circle_arrow_txt = self._icon
+81
View File
@@ -0,0 +1,81 @@
import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import MousePos
from openpilot.system.ui.widgets import Widget
ON_COLOR = rl.Color(51, 171, 76, 255)
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
KNOB_COLOR = rl.WHITE
DISABLED_ON_COLOR = rl.Color(0x22, 0x77, 0x22, 255) # Dark green when disabled + on
DISABLED_OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
DISABLED_KNOB_COLOR = rl.Color(0x88, 0x88, 0x88, 255)
WIDTH, HEIGHT = 160, 80
BG_HEIGHT = 60
ANIMATION_SPEED = 8.0
class Toggle(Widget):
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)
def _handle_mouse_release(self, mouse_pos: MousePos):
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) -> bool:
return self._state
def set_state(self, state: bool):
self._state = state
self._target = 1.0 if state else 0.0
def is_enabled(self):
return self._enabled
def update(self):
if abs(self._progress - self._target) > 0.01:
delta = rl.get_frame_time() * ANIMATION_SPEED
self._progress += delta if self._progress < self._target else -delta
self._progress = max(0.0, min(1.0, self._progress))
def _render(self, rect: rl.Rectangle):
self.update()
if self._enabled:
bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress)
knob_color = KNOB_COLOR
else:
bg_color = self._blend_color(DISABLED_OFF_COLOR, DISABLED_ON_COLOR, self._progress)
knob_color = DISABLED_KNOB_COLOR
# Draw background
bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT)
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
# Draw knob
knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress
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)