mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-12 19:33:48 +08:00
sunnypilot v2025.12.18-3820
version: sunnypilot v2025.003.000 (dev) date: 2025-12-18T05:48:43 master commit: 16c052af0835f049a67b80251d8cc1114fc08bb4
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# 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`
|
||||
* set `SUNNYPILOT_UI=0` to run the stock UI instead of the sunnypilot UI
|
||||
* 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 `_`
|
||||
@@ -0,0 +1,748 @@
|
||||
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
|
||||
|
||||
from openpilot.system.ui.sunnypilot.lib.application import GuiApplicationExt
|
||||
|
||||
_DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20}.get(HARDWARE.get_device_type(), 60)))
|
||||
FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops
|
||||
FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning
|
||||
FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions
|
||||
MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
|
||||
MAX_TOUCH_SLOTS = 2
|
||||
TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out
|
||||
|
||||
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 = "unifont.fnt"
|
||||
AUDIOWIDE = "Audiowide-Regular.fnt"
|
||||
|
||||
# Small UI fonts
|
||||
DISPLAY_REGULAR = "Inter-Regular.fnt"
|
||||
ROMAN = "Inter-Regular.fnt"
|
||||
DISPLAY = "Inter-Bold.fnt"
|
||||
|
||||
|
||||
def font_fallback(font: rl.Font) -> rl.Font:
|
||||
"""Fall back to unifont for languages that require it."""
|
||||
if multilang.requires_unifont():
|
||||
return gui_app.font(FontWeight.UNIFONT)
|
||||
return font
|
||||
|
||||
|
||||
@dataclass
|
||||
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(GuiApplicationExt):
|
||||
def __init__(self, width: int | None = None, height: int | None = None):
|
||||
self._fonts: dict[FontWeight, rl.Font] = {}
|
||||
self._width = width if width is not None else GuiApplication._default_width()
|
||||
self._height = height if height is not None else GuiApplication._default_height()
|
||||
|
||||
if PC and os.getenv("SCALE") is None:
|
||||
self._scale = self._calculate_auto_scale()
|
||||
else:
|
||||
self._scale = 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
|
||||
|
||||
GuiApplicationExt.__init__(self)
|
||||
|
||||
@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 = {}
|
||||
|
||||
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._show_mouse_coords:
|
||||
self._draw_mouse_coordinates(gui_app.font(FontWeight.SEMI_BOLD))
|
||||
|
||||
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:
|
||||
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):
|
||||
for font_weight_file in FontWeight:
|
||||
with as_file(FONT_DIR) as fspath:
|
||||
fnt_path = fspath / font_weight_file
|
||||
font = rl.load_font(fnt_path.as_posix())
|
||||
if font_weight_file != FontWeight.UNIFONT:
|
||||
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
self._fonts[font_weight_file] = font
|
||||
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
|
||||
|
||||
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() -> int:
|
||||
return 2160 if GuiApplication.big_ui() else 536
|
||||
|
||||
@staticmethod
|
||||
def _default_height() -> int:
|
||||
return 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()
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,138 @@
|
||||
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 only when the mouse cursor is over this panel
|
||||
mouse_wheel = rl.get_mouse_wheel_move()
|
||||
if mouse_wheel != 0:
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
if rl.check_collision_point_rec(mouse_pos, bounds):
|
||||
self._offset_filter_y.x += mouse_wheel * 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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'sunnypilot 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 * 4), 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()
|
||||
@@ -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
|
||||
Executable
+160
@@ -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. Partition 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()
|
||||
Executable
+760
@@ -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 sunnypilot 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()
|
||||
Executable
+200
@@ -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()
|
||||
Executable
+15
@@ -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()
|
||||
Executable
+15
@@ -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()
|
||||
Executable
+117
@@ -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("../../sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
|
||||
self._rotation = 0.0
|
||||
self._progress: int | None = None
|
||||
self._wrapped_lines: list[str] = []
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pyray as rl
|
||||
|
||||
SHOW_MOUSE_COORDS = os.getenv("SHOW_MOUSE_COORDS") == "1"
|
||||
SUNNYPILOT_UI = os.getenv("SUNNYPILOT_UI", "1") == "1"
|
||||
|
||||
|
||||
class GuiApplicationExt:
|
||||
def __init__(self):
|
||||
self._show_mouse_coords = SHOW_MOUSE_COORDS
|
||||
|
||||
@staticmethod
|
||||
def sunnypilot_ui() -> bool:
|
||||
return SUNNYPILOT_UI
|
||||
|
||||
def _draw_mouse_coordinates(self, font):
|
||||
coords_text = f"X:{int(rl.get_mouse_x())}, Y:{int(rl.get_mouse_y())}"
|
||||
|
||||
green_color = rl.Color(0, 159, 47, 255) # Match the green color of FPS counter
|
||||
|
||||
# Calculate text width to position it at the right edge; estimate width based on text length
|
||||
# Each character is approximately 10-12 pixels wide at font size 20
|
||||
estimated_text_width = len(coords_text) * 11
|
||||
|
||||
# Position text at the top right corner, 10px from the top
|
||||
screen_width = self._scaled_width if self._scale != 1.0 else self._width
|
||||
text_pos = rl.Vector2(screen_width - estimated_text_width - 10, 6)
|
||||
|
||||
# Draw the text
|
||||
rl.draw_text_ex(font, coords_text, text_pos, 20, 0, green_color)
|
||||
|
||||
def set_show_mouse_coords(self, show: bool):
|
||||
self._show_mouse_coords = show
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pyray as rl
|
||||
|
||||
|
||||
@dataclass
|
||||
class Base:
|
||||
# Widget/Control Base Dimensions
|
||||
ITEM_BASE_HEIGHT = 170
|
||||
ITEM_PADDING = 20
|
||||
ITEM_TEXT_FONT_SIZE = 50
|
||||
ITEM_DESC_FONT_SIZE = 40
|
||||
ITEM_DESC_V_OFFSET = 150
|
||||
ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255)
|
||||
CLOSE_BTN_SIZE = 160
|
||||
|
||||
TEXT_PADDING = 20
|
||||
|
||||
# Toggle Control
|
||||
TOGGLE_HEIGHT = 120
|
||||
TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75)
|
||||
TOGGLE_BG_HEIGHT = TOGGLE_HEIGHT - 20
|
||||
|
||||
# Button Control
|
||||
BUTTON_ACTION_WIDTH = 300
|
||||
BUTTON_HEIGHT = 120
|
||||
|
||||
# Simple Button Control
|
||||
SIMPLE_BUTTON_WIDTH = 800
|
||||
SIMPLE_BUTTON_HEIGHT = 150
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultStyleSP(Base):
|
||||
# Base Colors
|
||||
BASE_BG_COLOR = rl.Color(57, 57, 57, 255) # Grey
|
||||
ON_BG_COLOR = rl.Color(28, 101, 186, 255) # Blue
|
||||
OFF_BG_COLOR = BASE_BG_COLOR
|
||||
ON_HOVER_BG_COLOR = rl.Color(17, 78, 150, 255) # Dark Blue
|
||||
OFF_HOVER_BG_COLOR = rl.Color(21, 21, 21, 255) # Dark gray
|
||||
DISABLED_ON_BG_COLOR = rl.Color(37, 70, 107, 255) # Dull Blue
|
||||
DISABLED_OFF_BG_COLOR = rl.Color(39, 39, 39, 255) # Grey
|
||||
ITEM_TEXT_COLOR = rl.WHITE
|
||||
ITEM_DISABLED_TEXT_COLOR = rl.Color(88, 88, 88, 255)
|
||||
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
|
||||
|
||||
# Toggle Control
|
||||
TOGGLE_ON_COLOR = ON_BG_COLOR
|
||||
TOGGLE_OFF_COLOR = OFF_BG_COLOR
|
||||
TOGGLE_KNOB_COLOR = rl.WHITE
|
||||
TOGGLE_DISABLED_ON_COLOR = DISABLED_ON_BG_COLOR
|
||||
TOGGLE_DISABLED_OFF_COLOR = DISABLED_OFF_BG_COLOR
|
||||
TOGGLE_DISABLED_KNOB_COLOR = rl.Color(88, 88, 88, 255) # Lighter Grey
|
||||
|
||||
# Multi Button Control
|
||||
MBC_TRANSPARENT = rl.Color(255, 255, 255, 0)
|
||||
MBC_BG_CHECKED_ENABLED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
MBC_DISABLED = rl.Color(0xFF, 0xFF, 0xFF, 0x33)
|
||||
|
||||
# Option Control
|
||||
OPTION_CONTROL_CONTAINER_BG = OFF_BG_COLOR
|
||||
OPTION_CONTROL_BTN_ENABLED = rl.Color(88, 88, 88, 255)
|
||||
OPTION_CONTROL_BTN_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
OPTION_CONTROL_BTN_DISABLED = DISABLED_OFF_BG_COLOR
|
||||
OPTION_CONTROL_TEXT_ENABLED = rl.WHITE
|
||||
OPTION_CONTROL_TEXT_PRESSED = rl.WHITE
|
||||
OPTION_CONTROL_TEXT_DISABLED = ITEM_DISABLED_TEXT_COLOR
|
||||
|
||||
# Tree Button Colors
|
||||
BUTTON_PRIMARY_COLOR = rl.Color(70, 91, 234, 255) # Royal Blue
|
||||
BUTTON_NEUTRAL_GRAY = rl.Color(51, 51, 51, 255)
|
||||
BUTTON_DISABLED_BG_COLOR = rl.Color(30, 30, 30, 255) # Very Dark Grey
|
||||
TREE_DIALOG_TRANSPARENT = rl.Color(0, 0, 0, 0)
|
||||
TREE_DIALOG_SEARCH_BUTTON_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
TREE_DIALOG_SEARCH_BUTTON_BORDER = rl.Color(150, 150, 150, 200)
|
||||
|
||||
# Vehicle Description Colors
|
||||
GREEN = rl.Color(0, 241, 0, 255)
|
||||
BLUE = rl.Color(0, 134, 233, 255)
|
||||
YELLOW = rl.Color(255, 213, 0, 255)
|
||||
|
||||
# Button Colors
|
||||
BUTTON_ENABLED_OFF = rl.Color(0x39, 0x39, 0x39, 0xFF)
|
||||
BUTTON_OFF_PRESSED = rl.Color(0x4A, 0x4A, 0x4A, 0xFF)
|
||||
BUTTON_DISABLED = rl.Color(0x12, 0x12, 0x12, 0xFF)
|
||||
BUTTON_TEXT_DISABLED = rl.Color(0x5C, 0x5C, 0x5C, 0xFF)
|
||||
|
||||
|
||||
style = DefaultStyleSP
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ButtonActionSP
|
||||
|
||||
|
||||
class NoElideButtonAction(ButtonActionSP):
|
||||
def get_width_hint(self):
|
||||
return super().get_width_hint() + 1
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
return unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('utf-8').lower()
|
||||
|
||||
|
||||
def search_from_list(query: str, items: list[str]) -> list[str]:
|
||||
if not query:
|
||||
return items
|
||||
|
||||
normalized_query = normalize(query)
|
||||
search_terms = [re.sub(r'[^a-z0-9]', '', term) for term in normalized_query.split() if term.strip()]
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
normalized_item = normalize(item)
|
||||
item_with_spaces = re.sub(r'[^a-z0-9\s]', ' ', normalized_item)
|
||||
item_stripped = re.sub(r'[^a-z0-9]', '', normalized_item)
|
||||
|
||||
all_terms_match = True
|
||||
for term in search_terms:
|
||||
if not term:
|
||||
continue
|
||||
|
||||
if term not in item_with_spaces and term not in item_stripped:
|
||||
all_terms_match = False
|
||||
break
|
||||
|
||||
if all_terms_match:
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pyray as rl
|
||||
|
||||
|
||||
def draw_star(center_x, center_y, radius, is_filled, color):
|
||||
center = rl.Vector2(center_x, center_y)
|
||||
points = []
|
||||
|
||||
for i in range(10):
|
||||
angle = -(i * 36 + 18) * math.pi / 180
|
||||
r = radius if i % 2 == 0 else radius / 2
|
||||
x = center_x + r * math.cos(angle)
|
||||
y = center_y + r * math.sin(angle)
|
||||
points.append(rl.Vector2(x, y))
|
||||
|
||||
for i in range(10):
|
||||
if is_filled:
|
||||
rl.draw_triangle(center, points[i], points[(i + 1) % 10], color)
|
||||
rl.draw_line_ex(points[i], points[(i + 1) % 10], 2, color)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.html_render import HtmlModal
|
||||
|
||||
|
||||
class HtmlModalSP(HtmlModal):
|
||||
def __init__(self, file_path=None, text=None, callback=None):
|
||||
super().__init__(file_path=file_path, text=text)
|
||||
self._callback = callback
|
||||
self._dialog_result = DialogResult.NO_ACTION
|
||||
self._ok_button._click_callback = self._on_ok_clicked
|
||||
|
||||
def _on_ok_clicked(self):
|
||||
self._dialog_result = DialogResult.CONFIRM
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
if self._callback:
|
||||
self._callback(self._dialog_result)
|
||||
|
||||
def reset(self):
|
||||
self._dialog_result = DialogResult.NO_ACTION
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
|
||||
|
||||
class InputDialogSP:
|
||||
def __init__(self, title: str, sub_title: str | None = None, current_text: str = "", param: str | None = None,
|
||||
callback: Callable[[DialogResult, str], None] | None = None,
|
||||
min_text_size: int = 0, password_mode: bool = False):
|
||||
self.callback = callback
|
||||
self.current_text = current_text
|
||||
self.keyboard = Keyboard(max_text_size=255, min_text_size=min_text_size, password_mode=password_mode)
|
||||
self.param = param
|
||||
self._params = Params()
|
||||
self.sub_title = sub_title
|
||||
self.title = title
|
||||
|
||||
def show(self):
|
||||
self.keyboard.reset(min_text_size=self.keyboard._min_text_size)
|
||||
if self.sub_title:
|
||||
self.keyboard.set_title(self.title, self.sub_title)
|
||||
else:
|
||||
self.keyboard.set_title(self.title)
|
||||
self.keyboard.set_text(self.current_text)
|
||||
|
||||
def internal_callback(result: DialogResult):
|
||||
text = self.keyboard.text if result == DialogResult.CONFIRM else ""
|
||||
if result == DialogResult.CONFIRM and self.param:
|
||||
self._params.put(self.param, text)
|
||||
if self.callback:
|
||||
self.callback(result, text)
|
||||
|
||||
gui_app.set_modal_overlay(self.keyboard, internal_callback)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \
|
||||
_resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH
|
||||
|
||||
|
||||
class ToggleActionSP(ToggleAction):
|
||||
def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
|
||||
callback: Callable[[bool], None] | None = None, param: str | None = None):
|
||||
ToggleAction.__init__(self, initial_state, width, enabled, callback)
|
||||
self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param)
|
||||
|
||||
|
||||
class ButtonSP(Button):
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self.enabled:
|
||||
if self.is_pressed:
|
||||
self._background_color = style.BUTTON_OFF_PRESSED
|
||||
else:
|
||||
self._background_color = style.BUTTON_ENABLED_OFF
|
||||
else:
|
||||
self._background_color = style.BUTTON_DISABLED
|
||||
self._label.set_text_color(style.BUTTON_TEXT_DISABLED)
|
||||
|
||||
|
||||
class SimpleButtonActionSP(ItemAction):
|
||||
def __init__(self, button_text: str | Callable[[], str], callback: Callable = None,
|
||||
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH):
|
||||
super().__init__(width=button_width, enabled=enabled)
|
||||
self.button_action = ButtonSP(button_text, click_callback=callback, button_style=ButtonStyle.NORMAL,
|
||||
border_radius=20)
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(touch_callback)
|
||||
self.button_action.set_touch_valid_callback(touch_callback)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool | int | None:
|
||||
self.button_action.set_enabled(self.enabled)
|
||||
return self.button_action.render(rect)
|
||||
|
||||
|
||||
class ButtonActionSP(ButtonAction):
|
||||
def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_ACTION_WIDTH, enabled: bool | Callable[[], bool] = True):
|
||||
super().__init__(text=text, width=width, enabled=enabled)
|
||||
self._value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
|
||||
|
||||
def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
|
||||
self._value_source = value
|
||||
self._value_color = color
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool:
|
||||
"""Duplicate of ButtonAction._render, with additional value rendering"""
|
||||
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=style.ITEM_TEXT_FONT_SIZE, color=self._value_color,
|
||||
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
pressed = self._pressed
|
||||
self._pressed = False
|
||||
return pressed
|
||||
|
||||
|
||||
class MultipleButtonActionSP(MultipleButtonAction):
|
||||
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None,
|
||||
param: str | None = None):
|
||||
MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback)
|
||||
self.param_key = param
|
||||
self.params = Params()
|
||||
if self.param_key:
|
||||
self.selected_button = int(self.params.get(self.param_key, return_default=True))
|
||||
self._anim_x: float | None = None
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
|
||||
button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2
|
||||
|
||||
total_width = len(self.buttons) * self.button_width
|
||||
track_rect = rl.Rectangle(rect.x, button_y, total_width, style.BUTTON_HEIGHT)
|
||||
|
||||
bg_color = style.MBC_TRANSPARENT
|
||||
text_color = style.ITEM_TEXT_COLOR if self.enabled else style.MBC_DISABLED
|
||||
highlight_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED
|
||||
|
||||
# background
|
||||
rl.draw_rectangle_rounded(track_rect, 0.2, 20, bg_color)
|
||||
|
||||
# border
|
||||
border_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED
|
||||
rl.draw_rectangle_rounded_lines_ex(track_rect, 0.2, 20, 2, border_color)
|
||||
|
||||
# highlight with animation
|
||||
target_x = rect.x + self.selected_button * self.button_width
|
||||
if not self._anim_x:
|
||||
self._anim_x = target_x
|
||||
self._anim_x += (target_x - self._anim_x) * 0.2
|
||||
|
||||
highlight_rect = rl.Rectangle(self._anim_x, button_y, self.button_width, style.BUTTON_HEIGHT)
|
||||
rl.draw_rectangle_rounded(highlight_rect, 0.2, 20, highlight_color)
|
||||
|
||||
# text
|
||||
for i, _text in enumerate(self.buttons):
|
||||
button_x = rect.x + i * self.button_width
|
||||
|
||||
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 + (style.BUTTON_HEIGHT - text_size.y) / 2
|
||||
|
||||
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):
|
||||
MultipleButtonAction._handle_mouse_release(self, mouse_pos)
|
||||
if self.param_key:
|
||||
self.params.put(self.param_key, self.selected_button)
|
||||
|
||||
|
||||
class ListItemSP(ListItem):
|
||||
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, inline: bool = True, title_color: rl.Color = style.ITEM_TEXT_COLOR):
|
||||
ListItem.__init__(self, title, icon, description, description_visible, callback, action_item)
|
||||
self.title_color = title_color
|
||||
self.inline = inline
|
||||
if not self.inline:
|
||||
self._rect.height += style.ITEM_BASE_HEIGHT/1.75
|
||||
self._right_value_source: str | Callable[[], str] | None = None
|
||||
self._right_value_font = gui_app.font(FontWeight.NORMAL)
|
||||
self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
|
||||
|
||||
def set_right_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
|
||||
self._right_value_source = value
|
||||
self._right_value_color = color
|
||||
|
||||
@property
|
||||
def right_value(self) -> str:
|
||||
if self._right_value_source is None:
|
||||
return ""
|
||||
return str(_resolve_value(self._right_value_source, ""))
|
||||
|
||||
def get_item_height(self, font: rl.Font, max_width: int) -> float:
|
||||
height = super().get_item_height(font, max_width)
|
||||
|
||||
if self.description_visible:
|
||||
height += style.ITEM_PADDING * 1.5
|
||||
|
||||
if not self.inline:
|
||||
height += style.ITEM_BASE_HEIGHT / 1.75
|
||||
|
||||
return height
|
||||
|
||||
def show_description(self, show: bool):
|
||||
self._set_description_visible(show)
|
||||
|
||||
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
|
||||
if not self.action_item:
|
||||
return rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
if not self.inline:
|
||||
has_description = bool(self.description) and self.description_visible
|
||||
|
||||
if has_description:
|
||||
action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
|
||||
else:
|
||||
action_y = item_rect.y + item_rect.height - style.BUTTON_HEIGHT - style.ITEM_PADDING * 1.5
|
||||
|
||||
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT)
|
||||
|
||||
right_width = self.action_item.get_width_hint()
|
||||
if right_width == 0:
|
||||
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
|
||||
|
||||
content_width = item_rect.width - (style.ITEM_PADDING * 2)
|
||||
title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x
|
||||
right_width = min(content_width - title_width, right_width)
|
||||
if isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, SimpleButtonActionSP):
|
||||
action_x = item_rect.x
|
||||
else:
|
||||
action_x = item_rect.x + item_rect.width - right_width
|
||||
action_y = item_rect.y
|
||||
return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT)
|
||||
|
||||
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 + style.ITEM_PADDING
|
||||
text_x = content_x
|
||||
left_action_item = isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, SimpleButtonActionSP)
|
||||
|
||||
if left_action_item:
|
||||
item_height = style.SIMPLE_BUTTON_HEIGHT if isinstance(self.action_item, SimpleButtonActionSP) else style.TOGGLE_HEIGHT
|
||||
left_rect = rl.Rectangle(
|
||||
content_x,
|
||||
self._rect.y + (style.ITEM_BASE_HEIGHT - item_height) // 2,
|
||||
self.action_item.rect.width,
|
||||
item_height
|
||||
)
|
||||
text_x = left_rect.x + left_rect.width + style.ITEM_PADDING * 1.5
|
||||
|
||||
# Draw title
|
||||
if self.title:
|
||||
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
|
||||
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2
|
||||
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
|
||||
|
||||
value_text = self.right_value
|
||||
if value_text:
|
||||
# area from after the title to the right edge of the row
|
||||
value_rect = rl.Rectangle(
|
||||
text_x, # start at the beginning of the text area
|
||||
self._rect.y,
|
||||
self._rect.width - (text_x - self._rect.x) - style.ITEM_PADDING,
|
||||
style.ITEM_BASE_HEIGHT,
|
||||
)
|
||||
if value_rect.width > 0:
|
||||
gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
# Render toggle and handle callback
|
||||
if self.action_item.render(left_rect) and self.action_item.enabled:
|
||||
if self.callback:
|
||||
self.callback()
|
||||
|
||||
else:
|
||||
if self.title:
|
||||
# Draw main text
|
||||
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
|
||||
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5
|
||||
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
|
||||
|
||||
# Draw right item if present
|
||||
if self.action_item:
|
||||
right_rect = self.get_right_item_rect(self._rect)
|
||||
if self.action_item.render(right_rect) and self.action_item.enabled:
|
||||
# Right item was clicked/activated
|
||||
if self.callback:
|
||||
self.callback()
|
||||
|
||||
# Draw description if visible
|
||||
if self.description_visible:
|
||||
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
|
||||
description_height = self._html_renderer.get_total_height(content_width)
|
||||
|
||||
desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET
|
||||
if not self.inline and self.action_item:
|
||||
desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 0.5
|
||||
|
||||
description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height)
|
||||
self._html_renderer.render(description_rect)
|
||||
|
||||
|
||||
def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callable | None = None,
|
||||
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH) -> ListItemSP:
|
||||
action = SimpleButtonActionSP(button_text=button_text, enabled=enabled, callback=callback, button_width=button_width)
|
||||
return ListItemSP(title="", callback=callback, description="", action_item=action)
|
||||
|
||||
|
||||
def toggle_item_sp(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, param: str | None = None) -> ListItemSP:
|
||||
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
|
||||
|
||||
|
||||
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
|
||||
selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable = None,
|
||||
icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP:
|
||||
action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param)
|
||||
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)
|
||||
|
||||
|
||||
def option_item_sp(title: str | Callable[[], str], param: str,
|
||||
min_value: int, max_value: int, description: str | Callable[[], str] | None = None,
|
||||
value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None,
|
||||
enabled: bool | Callable[[], bool] = True,
|
||||
icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None,
|
||||
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None) -> ListItemSP:
|
||||
action = OptionControlSP(
|
||||
param, min_value, max_value, value_change_step,
|
||||
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
|
||||
)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon)
|
||||
|
||||
|
||||
def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP:
|
||||
action = ButtonActionSP(text=button_text, enabled=enabled)
|
||||
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
|
||||
@@ -0,0 +1,165 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.widgets.list_view import ItemAction
|
||||
|
||||
# Dimensions and styling constants
|
||||
BUTTON_WIDTH = 150
|
||||
BUTTON_HEIGHT = 150
|
||||
LABEL_WIDTH = 350
|
||||
BUTTON_SPACING = 25
|
||||
VALUE_FONT_SIZE = 50
|
||||
BUTTON_FONT_SIZE = 60
|
||||
CONTAINER_PADDING = 20
|
||||
|
||||
|
||||
class OptionControlSP(ItemAction):
|
||||
def __init__(self, param: str, min_value: int, max_value: int,
|
||||
value_change_step: int = 1, enabled: bool | Callable[[], bool] = True,
|
||||
on_value_changed: Callable[[int], None] | None = None,
|
||||
value_map: dict[int, int] | None = None,
|
||||
label_width: int = LABEL_WIDTH,
|
||||
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None):
|
||||
|
||||
super().__init__(enabled=enabled)
|
||||
self.params = Params()
|
||||
self.param_key = param
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self.value_change_step = value_change_step
|
||||
self._minus_enabled = enabled
|
||||
self._plus_enabled = enabled
|
||||
self.on_value_changed = on_value_changed
|
||||
self.value_map = value_map
|
||||
self.label_width = label_width
|
||||
self.use_float_scaling = use_float_scaling
|
||||
self.current_value = min_value
|
||||
self.label_callback = label_callback
|
||||
if self.value_map:
|
||||
for key in self.value_map:
|
||||
if self.value_map[key] == self.params.get(self.param_key, return_default=True):
|
||||
self.current_value = int(key)
|
||||
break
|
||||
else:
|
||||
self.current_value = int(self.params.get(self.param_key, return_default=True))
|
||||
|
||||
# Initialize font and button styles
|
||||
self._font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
# Layout rectangles for components
|
||||
self.minus_btn_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self.plus_btn_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
def get_value(self) -> int:
|
||||
"""Get the current value of the control"""
|
||||
return self.current_value
|
||||
|
||||
def set_value(self, value: int):
|
||||
"""Set the control to a specific value"""
|
||||
if self.min_value <= value <= self.max_value:
|
||||
self.current_value = value
|
||||
if self.value_map:
|
||||
self.params.put(self.param_key, self.value_map[value])
|
||||
else:
|
||||
if self.use_float_scaling:
|
||||
self.params.put(self.param_key, value / 100.0)
|
||||
else:
|
||||
self.params.put(self.param_key, value)
|
||||
if self.on_value_changed:
|
||||
self.on_value_changed(value)
|
||||
|
||||
def get_displayed_value(self) -> str:
|
||||
"""Get the displayed value, handling value mapping if present"""
|
||||
value = self.current_value
|
||||
|
||||
if callable(self.label_callback):
|
||||
if self.value_map:
|
||||
return self.label_callback(self.value_map[value])
|
||||
else:
|
||||
return self.label_callback(value)
|
||||
|
||||
if self.value_map:
|
||||
# Use the value map to get the display string
|
||||
if value in self.value_map:
|
||||
return str(self.value_map[value]) # Return the display string
|
||||
|
||||
# If using float scaling, format as float
|
||||
if self.use_float_scaling:
|
||||
return f"{value / 100.0:.2f}"
|
||||
|
||||
return str(value)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._rect.width == 0 or self._rect.height == 0 or not self.is_visible:
|
||||
return
|
||||
|
||||
control_width = (BUTTON_WIDTH * 2) + self.label_width + (BUTTON_SPACING * 2)
|
||||
total_width = control_width + (CONTAINER_PADDING * 2)
|
||||
self._rect.width = total_width
|
||||
|
||||
start_x = self._rect.x + self._rect.width - control_width - (CONTAINER_PADDING * 2)
|
||||
component_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
|
||||
self.container_rect = rl.Rectangle(start_x, component_y, total_width, BUTTON_HEIGHT)
|
||||
|
||||
# background
|
||||
rl.draw_rectangle_rounded(self.container_rect, 0.2, 20, style.OPTION_CONTROL_CONTAINER_BG)
|
||||
|
||||
# minus button
|
||||
self.minus_btn_rect = rl.Rectangle(self.container_rect.x, component_y, BUTTON_WIDTH + CONTAINER_PADDING,
|
||||
BUTTON_HEIGHT)
|
||||
|
||||
# label
|
||||
label_x = self.container_rect.x + CONTAINER_PADDING + BUTTON_WIDTH + BUTTON_SPACING
|
||||
self.label_rect = rl.Rectangle(label_x, component_y, self.label_width, BUTTON_HEIGHT)
|
||||
|
||||
# plus button
|
||||
plus_x = label_x + self.label_width + BUTTON_SPACING
|
||||
self.plus_btn_rect = rl.Rectangle(plus_x, component_y, BUTTON_WIDTH + CONTAINER_PADDING, BUTTON_HEIGHT)
|
||||
|
||||
self._minus_enabled = self.enabled and self.current_value > self.min_value
|
||||
self._plus_enabled = self.enabled and self.current_value < self.max_value
|
||||
|
||||
self._render_button(self.minus_btn_rect, "-", self._minus_enabled)
|
||||
self._render_value_label()
|
||||
self._render_button(self.plus_btn_rect, "+", self._plus_enabled)
|
||||
|
||||
def _render_button(self, rect: rl.Rectangle, text: str, enabled: bool):
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
is_pressed = (rl.check_collision_point_rec(mouse_pos, rect) and
|
||||
self._touch_valid() and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT))
|
||||
|
||||
text_color = style.ITEM_TEXT_COLOR if enabled else style.ITEM_DISABLED_TEXT_COLOR
|
||||
|
||||
# highlight
|
||||
if enabled and is_pressed:
|
||||
rl.draw_rectangle_rounded(rect, 0.2, 20, style.OPTION_CONTROL_BTN_PRESSED)
|
||||
|
||||
# button text
|
||||
text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE)
|
||||
text_x = rect.x + (rect.width - text_size.x) / 2
|
||||
text_y = rect.y + (rect.height - text_size.y) / 2
|
||||
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color)
|
||||
|
||||
def _render_value_label(self):
|
||||
"""Render the current value label"""
|
||||
text = self.get_displayed_value()
|
||||
text_color = style.ITEM_TEXT_COLOR if self.enabled else style.ITEM_DISABLED_TEXT_COLOR
|
||||
|
||||
text_size = measure_text_cached(self._font, text, VALUE_FONT_SIZE)
|
||||
text_x = self.label_rect.x + (self.label_rect.width - text_size.x) / 2
|
||||
text_y = self.label_rect.y + (self.label_rect.height - text_size.y) / 2
|
||||
|
||||
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), VALUE_FONT_SIZE, 0, text_color)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if self._minus_enabled and rl.check_collision_point_rec(mouse_pos, self.minus_btn_rect):
|
||||
self.current_value -= self.value_change_step
|
||||
self.current_value = max(self.min_value, self.current_value)
|
||||
elif self._plus_enabled and rl.check_collision_point_rec(mouse_pos, self.plus_btn_rect):
|
||||
self.current_value += self.value_change_step
|
||||
self.current_value = min(self.max_value, self.current_value)
|
||||
|
||||
self.set_value(self.current_value)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import pyray as rl
|
||||
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.widgets.list_view import ListItem, ItemAction
|
||||
|
||||
|
||||
class ProgressBarAction(ItemAction):
|
||||
def __init__(self, width=600):
|
||||
super().__init__(width=width)
|
||||
self.progress = 0.0
|
||||
self.text = ""
|
||||
self.show_progress = False
|
||||
self.text_color = rl.GRAY
|
||||
self._font = gui_app.font(FontWeight.NORMAL)
|
||||
|
||||
def update(self, progress, text, show_progress=False, text_color=rl.GRAY):
|
||||
self.progress = progress
|
||||
self.text = text
|
||||
self.show_progress = show_progress
|
||||
self.text_color = text_color
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
font_size = 40
|
||||
text_size = measure_text_cached(self._font, self.text, font_size)
|
||||
padding = 30
|
||||
bar_width = text_size.x + 2 * padding
|
||||
text_x = (bar_width - text_size.x) / 2
|
||||
|
||||
if self.show_progress and len(parts := self.text.split(' - ', 1)) == 2:
|
||||
prefix = parts[0]
|
||||
max_prefix_w = measure_text_cached(self._font, "100%", font_size).x
|
||||
current_prefix_w = measure_text_cached(self._font, prefix, font_size).x
|
||||
|
||||
bar_width = (text_size.x - current_prefix_w + max_prefix_w) + 2 * padding
|
||||
text_x = padding + (max_prefix_w - current_prefix_w)
|
||||
|
||||
bar_height = 60
|
||||
bar_rect = rl.Rectangle(rect.x + rect.width - bar_width, rect.y + (rect.height - bar_height) / 2, bar_width, bar_height)
|
||||
|
||||
if self.show_progress:
|
||||
inner_rect = rl.Rectangle(bar_rect.x + 4, bar_rect.y + 4, bar_rect.width - 8, bar_rect.height - 8)
|
||||
if inner_rect.width > 0:
|
||||
fill_width = max(0, min(inner_rect.width, inner_rect.width * (self.progress / 100.0)))
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(inner_rect.x, inner_rect.y, fill_width, inner_rect.height), 0.2, 10, rl.Color(30, 121, 232, 255))
|
||||
|
||||
rl.draw_text_ex(self._font, self.text, rl.Vector2(bar_rect.x + text_x, bar_rect.y + (bar_height - text_size.y) / 2), font_size, 0, self.text_color)
|
||||
|
||||
|
||||
def progress_item(title):
|
||||
action = ProgressBarAction()
|
||||
return ListItem(title=title, action_item=action)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
|
||||
class SunnylinkPairingDialog(PairingDialog):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self, sponsor_pairing: bool = False):
|
||||
PairingDialog.__init__(self)
|
||||
self._sponsor_pairing = sponsor_pairing
|
||||
self._is_paired_prev = ui_state.sunnylink_state.is_paired()
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
qr_string = "https://github.com/sponsors/sunnyhaibin"
|
||||
|
||||
if self._sponsor_pairing:
|
||||
try:
|
||||
sl_dongle_id = self.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
|
||||
token = SunnylinkApi(sl_dongle_id).get_token()
|
||||
inner_string = f"1|{sl_dongle_id}|{token}"
|
||||
payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8')
|
||||
qr_string = f"{API_HOST}/sso?state={payload_bytes}"
|
||||
except Exception:
|
||||
cloudlog.exception("Failed to get pairing token")
|
||||
|
||||
return qr_string
|
||||
|
||||
def _update_state(self):
|
||||
is_paired = ui_state.sunnylink_state.is_paired()
|
||||
if not self._is_paired_prev and is_paired:
|
||||
gui_app.set_modal_overlay(None)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> int:
|
||||
rl.clear_background(rl.Color(224, 224, 224, 255))
|
||||
|
||||
self._check_qr_refresh()
|
||||
|
||||
margin = 70
|
||||
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin)
|
||||
y = content_rect.y
|
||||
|
||||
# Close button
|
||||
close_size = 80
|
||||
pad = 20
|
||||
close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2)
|
||||
self._close_btn.render(close_rect)
|
||||
|
||||
y += close_size + 40
|
||||
|
||||
# Title
|
||||
title = tr("Pair your GitHub account") if self._sponsor_pairing else tr("Early Access: Become a sunnypilot Sponsor")
|
||||
title_font = gui_app.font(FontWeight.NORMAL)
|
||||
left_width = int(content_rect.width * 0.5 - 15)
|
||||
|
||||
title_wrapped = wrap_text(title_font, title, 75, left_width)
|
||||
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK)
|
||||
y += len(title_wrapped) * 75 + 60
|
||||
|
||||
# Two columns: instructions and QR code
|
||||
remaining_height = content_rect.height - (y - content_rect.y)
|
||||
right_width = content_rect.width // 2 - 20
|
||||
|
||||
# Instructions
|
||||
self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height))
|
||||
|
||||
# QR code
|
||||
qr_size = min(right_width, content_rect.height) - 40
|
||||
qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2
|
||||
qr_y = content_rect.y
|
||||
self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size))
|
||||
|
||||
return -1
|
||||
|
||||
def _render_instructions(self, rect: rl.Rectangle) -> None:
|
||||
if self._sponsor_pairing:
|
||||
instructions = [
|
||||
tr("Scan the QR code to login to your GitHub account"),
|
||||
tr("Follow the prompts to complete the pairing process"),
|
||||
tr("Re-enter the \"sunnylink\" panel to verify sponsorship status"),
|
||||
tr("If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai")
|
||||
]
|
||||
else:
|
||||
instructions = [
|
||||
tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"),
|
||||
tr("Choose your sponsorship tier and confirm your support"),
|
||||
tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues")
|
||||
]
|
||||
|
||||
font = gui_app.font(FontWeight.BOLD)
|
||||
y = rect.y
|
||||
|
||||
for i, text in enumerate(instructions):
|
||||
circle_radius = 25
|
||||
circle_x = rect.x + circle_radius + 15
|
||||
text_x = rect.x + circle_radius * 2 + 40
|
||||
text_width = rect.width - (circle_radius * 2 + 40)
|
||||
|
||||
wrapped = wrap_text(font, text, 47, int(text_width))
|
||||
text_height = len(wrapped) * 47
|
||||
circle_y = y + text_height // 2
|
||||
|
||||
# Circle and number
|
||||
rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255))
|
||||
number = str(i + 1)
|
||||
number_size = measure_text_cached(font, number, 30)
|
||||
rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE)
|
||||
|
||||
# Text
|
||||
rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK)
|
||||
y += text_height + 50
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = SunnylinkPairingDialog(sponsor_pairing=True)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
if result != -1:
|
||||
break
|
||||
finally:
|
||||
del pairing
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import MousePos
|
||||
from openpilot.system.ui.widgets.toggle import Toggle
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
|
||||
KNOB_PADDING = 5
|
||||
KNOB_RADIUS = style.TOGGLE_BG_HEIGHT / 2 - KNOB_PADDING
|
||||
|
||||
|
||||
class ToggleSP(Toggle):
|
||||
def __init__(self, initial_state=False, callback: Callable[[bool], None] | None = None, param: str | None = None):
|
||||
self.param_key = param
|
||||
self.params = Params()
|
||||
if self.param_key:
|
||||
initial_state = self.params.get_bool(self.param_key)
|
||||
Toggle.__init__(self, initial_state, callback)
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle):
|
||||
self._rect = rl.Rectangle(rect.x, rect.y, style.TOGGLE_WIDTH, style.TOGGLE_HEIGHT)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
if self._enabled and self.param_key:
|
||||
self.params.put_bool(self.param_key, self._state)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self.update()
|
||||
self._rect.y -= style.ITEM_PADDING / 2
|
||||
if self._enabled:
|
||||
bg_color = self._blend_color(style.TOGGLE_OFF_COLOR, style.TOGGLE_ON_COLOR, self._progress)
|
||||
knob_color = style.TOGGLE_KNOB_COLOR
|
||||
else:
|
||||
bg_color = self._blend_color(style.TOGGLE_DISABLED_OFF_COLOR, style.TOGGLE_DISABLED_ON_COLOR, self._progress)
|
||||
knob_color = style.TOGGLE_DISABLED_KNOB_COLOR
|
||||
|
||||
# Draw background
|
||||
bg_rect = rl.Rectangle(self._rect.x, self._rect.y, style.TOGGLE_WIDTH, style.TOGGLE_BG_HEIGHT)
|
||||
|
||||
# Draw actual background
|
||||
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
|
||||
|
||||
left_edge = bg_rect.x + KNOB_PADDING
|
||||
right_edge = bg_rect.x + bg_rect.width - KNOB_PADDING
|
||||
|
||||
knob_travel_distance = right_edge - left_edge - 2 * KNOB_RADIUS
|
||||
min_knob_x = left_edge + KNOB_RADIUS
|
||||
knob_x = min_knob_x + knob_travel_distance * self._progress
|
||||
knob_y = self._rect.y + style.TOGGLE_BG_HEIGHT / 2
|
||||
|
||||
rl.draw_circle(int(knob_x), int(knob_y), KNOB_RADIUS, knob_color)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle, BUTTON_PRESSED_BACKGROUND_COLORS
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
|
||||
|
||||
from openpilot.system.ui.sunnypilot.lib.styles import style
|
||||
from openpilot.system.ui.sunnypilot.widgets.helpers.fuzzy_search import search_from_list
|
||||
from openpilot.system.ui.sunnypilot.widgets.helpers.star_icon import draw_star
|
||||
from openpilot.system.ui.sunnypilot.widgets.input_dialog import InputDialogSP
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeNode:
|
||||
ref: str
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeFolder:
|
||||
folder: str
|
||||
nodes: list
|
||||
|
||||
|
||||
class TreeItemWidget(Button):
|
||||
def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False, is_expanded=False):
|
||||
super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
text_padding=20 + indent_level * 30, elide_right=True)
|
||||
self.text = text
|
||||
self.ref = ref
|
||||
self.is_folder = is_folder
|
||||
self.indent_level = indent_level
|
||||
self.is_favorite = is_favorite
|
||||
self.selected = False
|
||||
self._favorite_callback = favorite_callback
|
||||
self.text_padding = 20 + indent_level * 30
|
||||
self.border_radius = 10
|
||||
self.is_expanded = is_expanded
|
||||
|
||||
def _render(self, rect):
|
||||
indent = 60 * self.indent_level
|
||||
self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height)
|
||||
if self.is_pressed:
|
||||
color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
|
||||
elif self.selected and self.ref != "search_bar":
|
||||
color = style.BUTTON_PRIMARY_COLOR
|
||||
else:
|
||||
color = style.BUTTON_DISABLED_BG_COLOR
|
||||
roundness = self.border_radius / (min(self._rect.width, self._rect.height) / 2)
|
||||
rl.draw_rectangle_rounded(self._rect, roundness, 10, color)
|
||||
text_offset = self.text_padding + 20 - 15 if self.is_expanded and not self.is_folder and self.indent_level > 0 else self.text_padding + 20
|
||||
text_rect = rl.Rectangle(self._rect.x + text_offset, self._rect.y, self._rect.width - self.text_padding - 20 - 90, self._rect.height)
|
||||
self._label.render(text_rect)
|
||||
|
||||
if not self.is_folder and self._favorite_callback:
|
||||
draw_star(self._rect.x + self._rect.width - 90, self._rect.y + self._rect.height / 2, 40, self.is_favorite,
|
||||
style.ON_BG_COLOR if self.is_favorite else rl.GRAY)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
star_rect = rl.Rectangle(self._rect.x + self._rect.width - 90 - 40, self._rect.y + self._rect.height / 2 - 40, 80, 80)
|
||||
if not self.is_folder and self._favorite_callback and rl.check_collision_point_rec(mouse_pos, star_rect):
|
||||
self._favorite_callback()
|
||||
return True
|
||||
return super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
|
||||
class TreeOptionDialog(MultiOptionDialog):
|
||||
def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None,
|
||||
get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None):
|
||||
super().__init__(title, [], current_ref, option_font_weight)
|
||||
self.folders = folders
|
||||
self.selection_ref = current_ref
|
||||
self.fav_param = fav_param
|
||||
self.expanded = set()
|
||||
self.params = Params()
|
||||
val = self.params.get(fav_param) if fav_param else None
|
||||
self.favorites = set(val.split(';')) if val else set()
|
||||
self.query = ""
|
||||
self.search_prompt = search_prompt or tr("Search")
|
||||
self.get_folders_fn = get_folders_fn
|
||||
self.on_exit = on_exit
|
||||
self.display_func = display_func or (lambda node: node.data.get('display_name', node.ref))
|
||||
self.search_funcs = search_funcs or [lambda node: node.data.get('display_name', ''), lambda node: node.data.get('short_name', '')]
|
||||
self._search_rect = None
|
||||
self._search_width = 0.475
|
||||
|
||||
# Default title & overridable subtitle for InputDialogSP
|
||||
self.search_title = search_title or tr("Enter search query")
|
||||
self.search_subtitle = search_subtitle
|
||||
self.search_dialog = None
|
||||
self._search_pressed = False
|
||||
|
||||
self.selection_node = None
|
||||
# Try to match by ref, by display text, or fall back to "Default" when no ref is set
|
||||
for folder in self.folders:
|
||||
for node in folder.nodes:
|
||||
display = self.display_func(node)
|
||||
if (
|
||||
node.ref == current_ref or
|
||||
display == current_ref or
|
||||
(not current_ref and node.ref == "Default")
|
||||
):
|
||||
self.selection = display
|
||||
self.current = display
|
||||
self.selection_node = node
|
||||
break
|
||||
if self.selection_node is not None:
|
||||
break
|
||||
|
||||
self._build_visible_items()
|
||||
|
||||
def _on_search_confirm(self, result, text):
|
||||
if result == DialogResult.CONFIRM:
|
||||
self.query = text
|
||||
self._build_visible_items()
|
||||
gui_app.set_modal_overlay(self, callback=self.on_exit)
|
||||
|
||||
def _on_search_clicked(self):
|
||||
self.search_dialog = InputDialogSP(
|
||||
self.search_title,
|
||||
self.search_subtitle,
|
||||
current_text=self.query,
|
||||
callback=self._on_search_confirm,
|
||||
)
|
||||
self.search_dialog.show()
|
||||
|
||||
def _toggle_folder(self, folder):
|
||||
if folder.folder:
|
||||
if folder.folder in self.expanded:
|
||||
self.expanded.remove(folder.folder)
|
||||
else:
|
||||
self.expanded.add(folder.folder)
|
||||
if folder == self.folders[-1] and folder.folder in self.expanded:
|
||||
self.scroller.scroll_panel.set_offset(self.scroller.scroll_panel.offset - 200)
|
||||
self._build_visible_items(reset_scroll=False)
|
||||
|
||||
def _select_node(self, node):
|
||||
self.selection = self.display_func(node)
|
||||
self.selection_ref = node.ref
|
||||
|
||||
def _toggle_favorite(self, node):
|
||||
self.favorites.remove(node.ref) if node.ref in self.favorites else self.favorites.add(node.ref)
|
||||
if self.fav_param:
|
||||
self.params.put(self.fav_param, ';'.join(self.favorites))
|
||||
if self.get_folders_fn:
|
||||
self.folders = self.get_folders_fn(self.favorites)
|
||||
self._build_visible_items(reset_scroll=False)
|
||||
|
||||
def _build_visible_items(self, reset_scroll=True):
|
||||
self.visible_items = []
|
||||
|
||||
# Pinned selected item at the very top (if any)
|
||||
if getattr(self, "selection_node", None) is not None:
|
||||
node = self.selection_node
|
||||
display = self.display_func(node)
|
||||
self.selection = self.current = display
|
||||
favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
|
||||
self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 0,
|
||||
lambda node_ref=node: self._select_node(node_ref),
|
||||
favorite_cb, node.ref in self.favorites, is_expanded=True))
|
||||
|
||||
for folder in self.folders:
|
||||
nodes = [node for node in folder.nodes if not self.query or search_from_list(self.query, [search_func(node) for search_func in self.search_funcs])]
|
||||
if not nodes and self.query:
|
||||
continue
|
||||
expanded = folder.folder in self.expanded or not folder.folder or bool(self.query)
|
||||
if folder.folder:
|
||||
self.visible_items.append(TreeItemWidget(f"{'-' if expanded else '+'} {folder.folder}", "", True, 0,
|
||||
lambda folder_ref=folder: self._toggle_folder(folder_ref)))
|
||||
if expanded:
|
||||
for node in nodes:
|
||||
# Skip duplicate root-level item for the selected node
|
||||
if self.selection_node is not None and node.ref == self.selection_node.ref and not folder.folder:
|
||||
continue
|
||||
|
||||
favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
|
||||
self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0,
|
||||
lambda node_ref=node: self._select_node(node_ref),
|
||||
favorite_cb, node.ref in self.favorites, is_expanded=expanded))
|
||||
|
||||
self.option_buttons = self.visible_items
|
||||
self.options = [item.text for item in self.visible_items]
|
||||
self.scroller._items = self.visible_items
|
||||
if reset_scroll:
|
||||
self.scroller.scroll_panel.set_offset(0)
|
||||
|
||||
def _render(self, rect):
|
||||
dialog_content_rect = rl.Rectangle(rect.x + 50, rect.y + 50, rect.width - 100, rect.height - 100)
|
||||
rl.draw_rectangle_rounded(dialog_content_rect, 0.02, 20, rl.BLACK)
|
||||
|
||||
# Title on the left
|
||||
title_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 50, dialog_content_rect.width * 0.5, 70)
|
||||
gui_label(title_rect, self.title, 70, font_weight=FontWeight.BOLD)
|
||||
|
||||
# Search bar on the top right
|
||||
search_width = dialog_content_rect.width * self._search_width
|
||||
search_height = 110
|
||||
search_x = dialog_content_rect.x + dialog_content_rect.width - 50 - search_width
|
||||
search_y = dialog_content_rect.y + 40 # align roughly with title
|
||||
|
||||
self._search_rect = rl.Rectangle(search_x, search_y, search_width, search_height)
|
||||
|
||||
# Draw search field
|
||||
inset = 4
|
||||
roundness = 0.3
|
||||
input_rect = rl.Rectangle(self._search_rect.x + inset, self._search_rect.y + inset,
|
||||
self._search_rect.width - inset * 2, self._search_rect.height - inset * 2)
|
||||
|
||||
# Transparent fill (unpressed), white fill (pressed), border
|
||||
fill_color = style.TREE_DIALOG_SEARCH_BUTTON_PRESSED if self._search_pressed else style.TREE_DIALOG_TRANSPARENT
|
||||
rl.draw_rectangle_rounded(input_rect, roundness, 10, fill_color)
|
||||
rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, style.TREE_DIALOG_SEARCH_BUTTON_BORDER)
|
||||
|
||||
# Magnifying glass icon
|
||||
icon_color = rl.Color(180, 180, 180, 240)
|
||||
cx = input_rect.x + 60
|
||||
cy = input_rect.y + input_rect.height / 2 - 5
|
||||
radius = min(input_rect.height * 0.28, 26)
|
||||
|
||||
circle_thickness = 4
|
||||
for i in range(circle_thickness):
|
||||
rl.draw_circle_lines(int(cx), int(cy), radius - i, icon_color)
|
||||
|
||||
handle_thickness = 5
|
||||
inner_x = cx + radius * 0.65
|
||||
inner_y = cy + radius * 0.65
|
||||
outer_x = cx + radius * 1.45
|
||||
outer_y = cy + radius * 1.45
|
||||
|
||||
rl.draw_line_ex(rl.Vector2(inner_x, inner_y), rl.Vector2(outer_x, outer_y), handle_thickness, icon_color)
|
||||
|
||||
# User text (query), placed after the icon if present
|
||||
if self.query:
|
||||
text_start_x = outer_x + 45
|
||||
text_rect = rl.Rectangle(text_start_x, input_rect.y, input_rect.x + input_rect.width - text_start_x - 10, input_rect.height)
|
||||
gui_label(text_rect, self.query, 70, font_weight=FontWeight.MEDIUM)
|
||||
|
||||
options_top = self._search_rect.y + self._search_rect.height + 40
|
||||
options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, options_top, dialog_content_rect.width - 100,
|
||||
dialog_content_rect.height - (options_top - dialog_content_rect.y) - 210)
|
||||
|
||||
for index, option_text in enumerate(self.options):
|
||||
self.option_buttons[index].selected = (option_text == self.selection)
|
||||
self.option_buttons[index].set_button_style(ButtonStyle.PRIMARY if option_text == self.selection else ButtonStyle.NORMAL)
|
||||
self.option_buttons[index].set_rect(rl.Rectangle(0, 0, options_area_rect.width, 135))
|
||||
self.scroller.render(options_area_rect)
|
||||
|
||||
button_width = (dialog_content_rect.width - 150) / 2
|
||||
button_y_position = dialog_content_rect.y + dialog_content_rect.height - 160
|
||||
|
||||
cancel_rect = rl.Rectangle(dialog_content_rect.x + 50, button_y_position, button_width, 160)
|
||||
self.cancel_button.render(cancel_rect)
|
||||
|
||||
select_rect = rl.Rectangle(dialog_content_rect.x + 100 + button_width, button_y_position, button_width, 160)
|
||||
self.select_button.set_enabled(self.selection != self.current)
|
||||
self.select_button.render(select_rect)
|
||||
|
||||
return self._result
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos):
|
||||
if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
|
||||
self._search_pressed = True
|
||||
return True
|
||||
return super()._handle_mouse_press(mouse_pos)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
clicked_search = False
|
||||
if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
|
||||
clicked_search = self._search_pressed
|
||||
|
||||
self._search_pressed = False
|
||||
|
||||
if clicked_search:
|
||||
self._on_search_clicked()
|
||||
return True
|
||||
|
||||
return super()._handle_mouse_release(mouse_pos)
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/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:
|
||||
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))
|
||||
Executable
+136
@@ -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()
|
||||
Executable
+451
@@ -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()
|
||||
Executable
+173
@@ -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()
|
||||
Executable
+15
@@ -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()
|
||||
@@ -0,0 +1,388 @@
|
||||
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) -> bool:
|
||||
"""Optionally handle mouse press events."""
|
||||
return False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> bool:
|
||||
"""Optionally handle mouse release events."""
|
||||
if self._click_callback:
|
||||
self._click_callback()
|
||||
return False
|
||||
|
||||
def _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()
|
||||
@@ -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)
|
||||
@@ -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="")
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,466 @@
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# 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), rl.WHITE)
|
||||
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, ITEM_TEXT_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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,492 @@
|
||||
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
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import button_item_sp as button_item
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP as ListItem
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import ToggleActionSP as ToggleAction
|
||||
from openpilot.system.ui.sunnypilot.widgets.list_view import MultipleButtonActionSP as MultipleButtonAction
|
||||
|
||||
# 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)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
self._tethering_action.set_enabled(True)
|
||||
self._tethering_action.set_state(self._wifi_manager.is_tethering_active())
|
||||
self._tethering_password_action.set_enabled(True)
|
||||
|
||||
if self._wifi_manager.is_tethering_active() or self._wifi_manager.ipv4_address == "":
|
||||
self._wifi_metered_action.set_enabled(False)
|
||||
self._wifi_metered_action.selected_button = 0
|
||||
elif self._wifi_manager.ipv4_address != "":
|
||||
metered = self._wifi_manager.current_network_metered
|
||||
self._wifi_metered_action.set_enabled(True)
|
||||
self._wifi_metered_action.selected_button = int(metered) if metered in (MeteredType.UNKNOWN, MeteredType.YES, MeteredType.NO) else 0
|
||||
|
||||
def _toggle_tethering(self):
|
||||
checked = self._tethering_action.get_state()
|
||||
self._tethering_action.set_enabled(False)
|
||||
if checked:
|
||||
self._wifi_metered_action.set_enabled(False)
|
||||
self._wifi_manager.set_tethering_active(checked)
|
||||
|
||||
def _toggle_roaming(self):
|
||||
roaming_state = self._roaming_action.get_state()
|
||||
self._params.put_bool("GsmRoaming", roaming_state)
|
||||
self._wifi_manager.update_gsm_settings(roaming_state, self._params.get("GsmApn") or "", self._params.get_bool("GsmMetered"))
|
||||
|
||||
def _edit_apn(self):
|
||||
def update_apn(result):
|
||||
if result != 1:
|
||||
return
|
||||
|
||||
apn = self._keyboard.text.strip()
|
||||
if apn == "":
|
||||
self._params.remove("GsmApn")
|
||||
else:
|
||||
self._params.put("GsmApn", apn)
|
||||
|
||||
self._wifi_manager.update_gsm_settings(self._params.get_bool("GsmRoaming"), apn, self._params.get_bool("GsmMetered"))
|
||||
|
||||
current_apn = self._params.get("GsmApn") or ""
|
||||
self._keyboard.reset(min_text_size=0)
|
||||
self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration"))
|
||||
self._keyboard.set_text(current_apn)
|
||||
gui_app.set_modal_overlay(self._keyboard, update_apn)
|
||||
|
||||
def _toggle_cellular_metered(self):
|
||||
metered = self._cellular_metered_action.get_state()
|
||||
self._params.put_bool("GsmMetered", metered)
|
||||
self._wifi_manager.update_gsm_settings(self._params.get_bool("GsmRoaming"), self._params.get("GsmApn") or "", metered)
|
||||
|
||||
def _toggle_wifi_metered(self, metered):
|
||||
metered_type = {0: MeteredType.UNKNOWN, 1: MeteredType.YES, 2: MeteredType.NO}.get(metered, MeteredType.UNKNOWN)
|
||||
self._wifi_metered_action.set_enabled(False)
|
||||
self._wifi_manager.set_current_network_metered(metered_type)
|
||||
|
||||
def _connect_to_hidden_network(self):
|
||||
def connect_hidden(result):
|
||||
if result != 1:
|
||||
return
|
||||
|
||||
ssid = self._keyboard.text
|
||||
if not ssid:
|
||||
return
|
||||
|
||||
def enter_password(result):
|
||||
password = self._keyboard.text
|
||||
if password == "":
|
||||
# connect without password
|
||||
self._wifi_manager.connect_to_network(ssid, "", hidden=True)
|
||||
return
|
||||
|
||||
self._wifi_manager.connect_to_network(ssid, password, hidden=True)
|
||||
|
||||
self._keyboard.reset(min_text_size=0)
|
||||
self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid))
|
||||
gui_app.set_modal_overlay(self._keyboard, enter_password)
|
||||
|
||||
self._keyboard.reset(min_text_size=1)
|
||||
self._keyboard.set_title(tr("Enter SSID"), "")
|
||||
gui_app.set_modal_overlay(self._keyboard, connect_hidden)
|
||||
|
||||
def _edit_tethering_password(self):
|
||||
def update_password(result):
|
||||
if result != 1:
|
||||
return
|
||||
|
||||
password = self._keyboard.text
|
||||
self._wifi_manager.set_tethering_password(password)
|
||||
self._tethering_password_action.set_enabled(False)
|
||||
|
||||
self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
|
||||
self._keyboard.set_title(tr("Enter new tethering password"), "")
|
||||
self._keyboard.set_text(self._wifi_manager.tethering_password)
|
||||
gui_app.set_modal_overlay(self._keyboard, update_password)
|
||||
|
||||
def _update_state(self):
|
||||
self._wifi_manager.process_callbacks()
|
||||
|
||||
# If not using prime SIM, show GSM settings and enable IPv4 forwarding
|
||||
show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE)
|
||||
self._wifi_manager.set_ipv4_forward(show_cell_settings)
|
||||
self._roaming_btn.set_visible(show_cell_settings)
|
||||
self._apn_btn.set_visible(show_cell_settings)
|
||||
self._cellular_metered_btn.set_visible(show_cell_settings)
|
||||
|
||||
def _render(self, _):
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
class WifiManagerUI(Widget):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self.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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user