openpilot v0.11.1
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# ui
|
||||
|
||||
The user interfaces here are built with [raylib](https://www.raylib.com/).
|
||||
|
||||
Quick start:
|
||||
* set `BIG=1` to run the comma 3X UI (comma four UI runs by default)
|
||||
* set `SHOW_FPS=1` to show the FPS
|
||||
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
|
||||
* set `SCALE=1.5` to scale the entire UI by 1.5x
|
||||
* set `BURN_IN=1` to get a burn-in heatmap version of the UI
|
||||
* set `GRID=50` to show a 50-pixel alignment grid overlay
|
||||
* set `MAGIC_DEBUG=1` to show every dropped frames (only on device)
|
||||
* set `RECORD=1` to record the screen, output defaults to `output.mp4` but can be set with `RECORD_OUTPUT`
|
||||
* https://www.raylib.com/cheatsheet/cheatsheet.html
|
||||
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
|
||||
|
||||
Style guide:
|
||||
* All graphical elements should subclass [`Widget`](/system/ui/widgets/__init__.py).
|
||||
* Prefer a stateful widget over a function for easy migration from QT
|
||||
* All internal class variables and functions should be prefixed with `_`
|
||||
@@ -0,0 +1,860 @@
|
||||
import atexit
|
||||
import cffi
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
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 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
|
||||
|
||||
_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"))
|
||||
RECORD_QUALITY = int(os.getenv("RECORD_QUALITY", "23")) # Dynamic bitrate quality level (CRF); 0 is lossless (bigger size), max is 51, default is 23 for x264
|
||||
RECORD_BITRATE = os.getenv("RECORD_BITRATE", "") # Target bitrate e.g. "2000k" (overrides RECORD_QUALITY when set)
|
||||
RECORD_SPEED = int(os.getenv("RECORD_SPEED", "1")) # Speed multiplier
|
||||
OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offline rendering
|
||||
|
||||
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):
|
||||
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"
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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):
|
||||
# TODO: read touch events from evdev directly to get real kernel timestamps.
|
||||
# Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll
|
||||
# velocity oscillate (alternating high/low). Real timestamps would also let us
|
||||
# detect swipe-stop-lift via event gaps instead of the fragile decel heuristic.
|
||||
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
|
||||
prev = self._prev_mouse_event[slot]
|
||||
if prev is None or ev[:-1] != prev[:-1]:
|
||||
with self._lock:
|
||||
self._events.append(ev)
|
||||
self._prev_mouse_event[slot] = ev
|
||||
|
||||
|
||||
class GuiApplication:
|
||||
def __init__(self, width: int | None = None, height: int | None = None):
|
||||
self._set_log_callback()
|
||||
|
||||
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._ffmpeg_queue: queue.Queue | None = None
|
||||
self._ffmpeg_thread: threading.Thread | None = None
|
||||
self._ffmpeg_stop_event: threading.Event | 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._nav_stack: list[object] = []
|
||||
self._nav_stack_ticks: list[Callable[[], None]] = []
|
||||
self._nav_stack_widgets_to_render = 1 if self.big_ui() else 2
|
||||
|
||||
self._mouse = MouseState(self._scale)
|
||||
self._mouse_events: list[MouseEvent] = []
|
||||
self._last_mouse_event: MouseEvent = MouseEvent(MousePos(0, 0), 0, False, False, False, 0.0)
|
||||
|
||||
self._should_render = True
|
||||
|
||||
# Debug variables
|
||||
self._mouse_history: deque[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE)
|
||||
self._show_touches = SHOW_TOUCHES
|
||||
self._show_fps = SHOW_FPS
|
||||
self._grid_size = GRID_SIZE
|
||||
self._profile_render_frames = PROFILE_RENDER
|
||||
self._render_profiler = None
|
||||
self._render_profile_start_time = None
|
||||
|
||||
@property
|
||||
def frame(self):
|
||||
return self._frame
|
||||
|
||||
def set_show_touches(self, show: bool):
|
||||
self._show_touches = show
|
||||
|
||||
def set_show_fps(self, show: bool):
|
||||
self._show_fps = show
|
||||
|
||||
@property
|
||||
def show_touches(self) -> bool:
|
||||
return self._show_touches
|
||||
|
||||
@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)
|
||||
|
||||
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._scaled_width, self._scaled_height)
|
||||
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
|
||||
if RECORD:
|
||||
output_fps = fps * RECORD_SPEED
|
||||
ffmpeg_args = [
|
||||
'ffmpeg',
|
||||
'-v', 'warning', # Reduce ffmpeg log spam
|
||||
'-nostats', # Suppress encoding progress
|
||||
'-f', 'rawvideo', # Input format
|
||||
'-pix_fmt', 'rgba', # Input pixel format
|
||||
'-s', f'{self._scaled_width}x{self._scaled_height}', # Input resolution
|
||||
'-r', str(fps), # Input frame rate
|
||||
'-i', 'pipe:0', # Input from stdin
|
||||
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p
|
||||
'-r', str(output_fps), # Output frame rate (for speed multiplier)
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', str(RECORD_QUALITY)
|
||||
]
|
||||
if RECORD_BITRATE:
|
||||
# NOTE: custom bitrate overrides crf setting
|
||||
ffmpeg_args += ['-b:v', RECORD_BITRATE, '-maxrate', RECORD_BITRATE, '-bufsize', RECORD_BITRATE]
|
||||
ffmpeg_args += [
|
||||
'-y', # Overwrite existing file
|
||||
'-f', 'mp4', # Output format
|
||||
RECORD_OUTPUT, # Output file path
|
||||
]
|
||||
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
|
||||
self._ffmpeg_queue = queue.Queue(maxsize=60) # Buffer up to 60 frames
|
||||
self._ffmpeg_stop_event = threading.Event()
|
||||
self._ffmpeg_thread = threading.Thread(target=self._ffmpeg_writer_thread, daemon=True)
|
||||
self._ffmpeg_thread.start()
|
||||
|
||||
# OFFSCREEN disables FPS limiting for fast offline rendering (e.g. clips)
|
||||
rl.set_target_fps(0 if OFFSCREEN else fps)
|
||||
|
||||
self._target_fps = fps
|
||||
self._set_styles()
|
||||
self._load_fonts()
|
||||
self._patch_text_functions()
|
||||
self._patch_scissor_mode()
|
||||
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 _ffmpeg_writer_thread(self):
|
||||
"""Background thread that writes frames to ffmpeg."""
|
||||
while True:
|
||||
try:
|
||||
data = self._ffmpeg_queue.get(timeout=1.0)
|
||||
if data is None: # Sentinel to stop
|
||||
break
|
||||
self._ffmpeg_proc.stdin.write(data)
|
||||
except queue.Empty:
|
||||
if self._ffmpeg_stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def push_widget(self, widget: object):
|
||||
if widget in self._nav_stack:
|
||||
cloudlog.warning("Widget already in stack, cannot push again!")
|
||||
return
|
||||
|
||||
# disable previous widget to prevent input processing
|
||||
if len(self._nav_stack) > 0:
|
||||
prev_widget = self._nav_stack[-1]
|
||||
# TODO: change these to touch_valid
|
||||
prev_widget.set_enabled(False)
|
||||
|
||||
self._nav_stack.append(widget)
|
||||
widget.show_event()
|
||||
widget.set_enabled(True)
|
||||
|
||||
def pop_widget(self, idx: int | None = None):
|
||||
# Pops widget instantly without animation
|
||||
if len(self._nav_stack) < 2:
|
||||
cloudlog.warning("At least one widget should remain on the stack, ignoring pop!")
|
||||
return
|
||||
|
||||
idx_to_pop = len(self._nav_stack) - 1 if idx is None else idx
|
||||
if idx_to_pop <= 0 or idx_to_pop >= len(self._nav_stack):
|
||||
cloudlog.warning(f"Invalid index {idx_to_pop} to pop, ignoring!")
|
||||
return
|
||||
|
||||
# only re-enable previous widget if popping top widget
|
||||
if idx_to_pop == len(self._nav_stack) - 1:
|
||||
prev_widget = self._nav_stack[idx_to_pop - 1]
|
||||
prev_widget.set_enabled(True)
|
||||
|
||||
widget = self._nav_stack.pop(idx_to_pop)
|
||||
widget.hide_event()
|
||||
|
||||
def pop_widgets_to(self, widget: object, callback: Callable[[], None] | None = None, instant: bool = False):
|
||||
# Pops middle widgets instantly without animation then dismisses top, animated out if NavWidget
|
||||
if widget not in self._nav_stack:
|
||||
cloudlog.warning("Widget not in stack, cannot pop to it!")
|
||||
return
|
||||
|
||||
# Nothing to pop, ensure we still run callback
|
||||
top_widget = self._nav_stack[-1]
|
||||
if top_widget == widget:
|
||||
if callback:
|
||||
callback()
|
||||
return
|
||||
|
||||
# instantly pop widgets in between, then dismiss top widget for animation
|
||||
while len(self._nav_stack) > 1 and self._nav_stack[-2] != widget:
|
||||
self.pop_widget(len(self._nav_stack) - 2)
|
||||
|
||||
if not instant:
|
||||
top_widget.dismiss(callback)
|
||||
else:
|
||||
self.pop_widget()
|
||||
|
||||
def get_active_widget(self):
|
||||
if len(self._nav_stack) > 0:
|
||||
return self._nav_stack[-1]
|
||||
return None
|
||||
|
||||
def widget_in_stack(self, widget: object) -> bool:
|
||||
return widget in self._nav_stack
|
||||
|
||||
def add_nav_stack_tick(self, tick_function: Callable[[], None]):
|
||||
if tick_function not in self._nav_stack_ticks:
|
||||
self._nav_stack_ticks.append(tick_function)
|
||||
|
||||
def remove_nav_stack_tick(self, tick_function: Callable[[], None]):
|
||||
if tick_function in self._nav_stack_ticks:
|
||||
self._nav_stack_ticks.remove(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, flip_x: bool = False) -> rl.Texture:
|
||||
if width is not None:
|
||||
width = round(width)
|
||||
if height is not None:
|
||||
height = round(height)
|
||||
|
||||
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}_{keep_aspect_ratio}_{flip_x}"
|
||||
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, flip_x)
|
||||
texture_obj = self._load_texture_from_image(image_obj)
|
||||
|
||||
# Set logical size so widget layout math stays at 1x coordinates
|
||||
if self._scale != 1.0 and width is not None and height is not None:
|
||||
texture_obj.width = width
|
||||
texture_obj.height = height
|
||||
|
||||
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, flip_x: bool = False) -> 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)
|
||||
|
||||
# Scale up load size for sharper rendering, capped at source resolution
|
||||
if self._scale != 1.0 and width is not None and height is not None:
|
||||
width = min(int(width * self._scale), image.width)
|
||||
height = min(int(height * self._scale), image.height)
|
||||
|
||||
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"
|
||||
|
||||
if flip_x:
|
||||
rl.image_flip_horizontal(image)
|
||||
|
||||
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_thread is not None:
|
||||
# Signal thread to stop, send sentinel, then wait for it to drain
|
||||
self._ffmpeg_stop_event.set()
|
||||
self._ffmpeg_queue.put(None)
|
||||
self._ffmpeg_thread.join(timeout=30)
|
||||
|
||||
if self._ffmpeg_proc is not None:
|
||||
self._ffmpeg_proc.stdin.flush()
|
||||
self._ffmpeg_proc.stdin.close()
|
||||
try:
|
||||
self._ffmpeg_proc.wait(timeout=30)
|
||||
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)
|
||||
|
||||
if self._scale != 1.0:
|
||||
rl.rl_push_matrix()
|
||||
rl.rl_scalef(self._scale, self._scale, 1.0)
|
||||
|
||||
# Allow a Widget to still run a function regardless of the stack depth
|
||||
for tick in self._nav_stack_ticks:
|
||||
tick()
|
||||
|
||||
# Only render top widgets
|
||||
for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]:
|
||||
widget.render(rl.Rectangle(0, 0, self.width, self.height))
|
||||
|
||||
yield True
|
||||
|
||||
if self._scale != 1.0:
|
||||
rl.rl_pop_matrix()
|
||||
|
||||
if self._render_texture:
|
||||
rl.end_texture_mode()
|
||||
rl.begin_drawing()
|
||||
rl.clear_background(rl.BLACK)
|
||||
src_rect = rl.Rectangle(0, 0, float(self._scaled_width), -float(self._scaled_height))
|
||||
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
|
||||
texture = self._render_texture.texture
|
||||
if texture:
|
||||
if BURN_IN_MODE and self._burn_in_shader:
|
||||
rl.begin_shader_mode(self._burn_in_shader)
|
||||
rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
rl.end_shader_mode()
|
||||
else:
|
||||
rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
|
||||
if self._show_fps:
|
||||
rl.draw_fps(10, 10)
|
||||
|
||||
if self._show_touches:
|
||||
self._draw_touch_points()
|
||||
|
||||
if self._grid_size > 0:
|
||||
self._draw_grid()
|
||||
|
||||
rl.end_drawing()
|
||||
|
||||
if RECORD:
|
||||
image = rl.load_image_from_texture(self._render_texture.texture)
|
||||
data_size = image.width * image.height * 4
|
||||
data = bytes(rl.ffi.buffer(image.data, data_size))
|
||||
self._ffmpeg_queue.put(data) # Async write via background thread
|
||||
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 _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.gen_texture_mipmaps(font.texture)
|
||||
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
|
||||
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 _patch_scissor_mode(self):
|
||||
if self._scale == 1.0:
|
||||
return
|
||||
|
||||
if not hasattr(rl, "_orig_begin_scissor_mode"):
|
||||
rl._orig_begin_scissor_mode = rl.begin_scissor_mode
|
||||
|
||||
def _begin_scissor_mode_scaled(x, y, width, height):
|
||||
return rl._orig_begin_scissor_mode(
|
||||
int(x * self._scale), int(y * self._scale),
|
||||
int(math.ceil(width * self._scale)), int(math.ceil(height * self._scale)))
|
||||
|
||||
rl.begin_scissor_mode = _begin_scissor_mode_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}")
|
||||
|
||||
# ensure we get all the logs forwarded to us
|
||||
rl.set_trace_log_level(rl.TraceLogLevel.LOG_DEBUG)
|
||||
|
||||
# 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
|
||||
import functools
|
||||
from importlib.resources import as_file
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import FONT_DIR
|
||||
|
||||
_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
|
||||
)
|
||||
|
||||
@functools.cache
|
||||
def _load_emoji_font() -> ImageFont.FreeTypeFont:
|
||||
with as_file(FONT_DIR.joinpath("NotoColorEmoji.ttf")) as font_path:
|
||||
return ImageFont.truetype(io.BytesIO(font_path.read_bytes()), 109)
|
||||
|
||||
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,213 @@
|
||||
from importlib.resources import files
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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 = [
|
||||
"th",
|
||||
"zh-CHT",
|
||||
"zh-CHS",
|
||||
"ko",
|
||||
"ja",
|
||||
]
|
||||
|
||||
# Plural form selectors for supported languages
|
||||
PLURAL_SELECTORS = {
|
||||
'en': lambda n: 0 if n == 1 else 1,
|
||||
'de': lambda n: 0 if n == 1 else 1,
|
||||
'fr': lambda n: 0 if n <= 1 else 1,
|
||||
'pt-BR': lambda n: 0 if n <= 1 else 1,
|
||||
'es': lambda n: 0 if n == 1 else 1,
|
||||
'tr': lambda n: 0 if n == 1 else 1,
|
||||
'uk': lambda n: 0 if n % 10 == 1 and n % 100 != 11 else (1 if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14 else 2),
|
||||
'th': lambda n: 0,
|
||||
'zh-CHT': lambda n: 0,
|
||||
'zh-CHS': lambda n: 0,
|
||||
'ko': lambda n: 0,
|
||||
'ja': lambda n: 0,
|
||||
}
|
||||
|
||||
|
||||
def _parse_quoted(s: str) -> str:
|
||||
"""Parse a PO-format quoted string."""
|
||||
s = s.strip()
|
||||
if not (s.startswith('"') and s.endswith('"')):
|
||||
raise ValueError(f"Expected quoted string: {s!r}")
|
||||
s = s[1:-1]
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
if s[i] == '\\' and i + 1 < len(s):
|
||||
c = s[i + 1]
|
||||
if c == 'n':
|
||||
result.append('\n')
|
||||
elif c == 't':
|
||||
result.append('\t')
|
||||
elif c == '"':
|
||||
result.append('"')
|
||||
elif c == '\\':
|
||||
result.append('\\')
|
||||
else:
|
||||
result.append(s[i:i + 2])
|
||||
i += 2
|
||||
else:
|
||||
result.append(s[i])
|
||||
i += 1
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def load_translations(path) -> tuple[dict[str, str], dict[str, list[str]]]:
|
||||
"""Parse a .po file and return (translations, plurals) dicts.
|
||||
|
||||
translations: msgid -> msgstr
|
||||
plurals: msgid -> [msgstr[0], msgstr[1], ...]
|
||||
"""
|
||||
with path.open(encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
translations: dict[str, str] = {}
|
||||
plurals: dict[str, list[str]] = {}
|
||||
|
||||
# Parser state
|
||||
msgid = msgid_plural = msgstr = ""
|
||||
msgstr_plurals: dict[int, str] = {}
|
||||
field: str | None = None
|
||||
plural_idx = 0
|
||||
|
||||
def finish():
|
||||
nonlocal msgid, msgid_plural, msgstr, msgstr_plurals, field
|
||||
if msgid: # skip header (empty msgid)
|
||||
if msgid_plural:
|
||||
max_idx = max(msgstr_plurals.keys()) if msgstr_plurals else 0
|
||||
plurals[msgid] = [msgstr_plurals.get(i, '') for i in range(max_idx + 1)]
|
||||
else:
|
||||
translations[msgid] = msgstr
|
||||
msgid = msgid_plural = msgstr = ""
|
||||
msgstr_plurals = {}
|
||||
field = None
|
||||
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
|
||||
if not line:
|
||||
finish()
|
||||
continue
|
||||
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
|
||||
if line.startswith('msgid_plural '):
|
||||
msgid_plural = _parse_quoted(line[len('msgid_plural '):])
|
||||
field = 'msgid_plural'
|
||||
continue
|
||||
|
||||
if line.startswith('msgid '):
|
||||
msgid = _parse_quoted(line[len('msgid '):])
|
||||
field = 'msgid'
|
||||
continue
|
||||
|
||||
m = re.match(r'msgstr\[(\d+)]\s+(.*)', line)
|
||||
if m:
|
||||
plural_idx = int(m.group(1))
|
||||
msgstr_plurals[plural_idx] = _parse_quoted(m.group(2))
|
||||
field = 'msgstr_plural'
|
||||
continue
|
||||
|
||||
if line.startswith('msgstr '):
|
||||
msgstr = _parse_quoted(line[len('msgstr '):])
|
||||
field = 'msgstr'
|
||||
continue
|
||||
|
||||
if line.startswith('"'):
|
||||
val = _parse_quoted(line)
|
||||
if field == 'msgid':
|
||||
msgid += val
|
||||
elif field == 'msgid_plural':
|
||||
msgid_plural += val
|
||||
elif field == 'msgstr':
|
||||
msgstr += val
|
||||
elif field == 'msgstr_plural':
|
||||
msgstr_plurals[plural_idx] += val
|
||||
|
||||
finish()
|
||||
return translations, plurals
|
||||
|
||||
|
||||
class Multilang:
|
||||
def __init__(self):
|
||||
self._params = Params() if Params is not None else None
|
||||
self._language: str = "en"
|
||||
self.languages: dict[str, str] = {}
|
||||
self.codes: dict[str, str] = {}
|
||||
self._translations: dict[str, str] = {}
|
||||
self._plurals: dict[str, list[str]] = {}
|
||||
self._plural_selector = PLURAL_SELECTORS.get('en', lambda n: 0)
|
||||
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:
|
||||
po_path = TRANSLATIONS_DIR.joinpath(f'app_{self._language}.po')
|
||||
self._translations, self._plurals = load_translations(po_path)
|
||||
self._plural_selector = PLURAL_SELECTORS.get(self._language, lambda n: 0)
|
||||
cloudlog.debug(f"Loaded translations for language: {self._language}")
|
||||
except FileNotFoundError:
|
||||
cloudlog.error(f"No translation file found for language: {self._language}, using default.")
|
||||
self._translations = {}
|
||||
self._plurals = {}
|
||||
|
||||
def change_language(self, language_code: str) -> None:
|
||||
self._params.put("LanguageSetting", language_code)
|
||||
self._language = language_code
|
||||
self.setup()
|
||||
|
||||
def tr(self, text: str) -> str:
|
||||
return self._translations.get(text, text) or text
|
||||
|
||||
def trn(self, singular: str, plural: str, n: int) -> str:
|
||||
if singular in self._plurals:
|
||||
idx = self._plural_selector(n)
|
||||
forms = self._plurals[singular]
|
||||
if idx < len(forms) and forms[idx]:
|
||||
return forms[idx]
|
||||
return singular if n == 1 else plural
|
||||
|
||||
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,64 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
# NetworkManager device states
|
||||
class NMDeviceState(IntEnum):
|
||||
# https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceState
|
||||
UNKNOWN = 0
|
||||
UNMANAGED = 10
|
||||
UNAVAILABLE = 20
|
||||
DISCONNECTED = 30
|
||||
PREPARE = 40
|
||||
CONFIG = 50
|
||||
NEED_AUTH = 60
|
||||
IP_CONFIG = 70
|
||||
IP_CHECK = 80
|
||||
SECONDARIES = 90
|
||||
ACTIVATED = 100
|
||||
DEACTIVATING = 110
|
||||
FAILED = 120
|
||||
|
||||
|
||||
class NMDeviceStateReason(IntEnum):
|
||||
# https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceStateReason
|
||||
NONE = 0
|
||||
UNKNOWN = 1
|
||||
IP_CONFIG_UNAVAILABLE = 5
|
||||
NO_SECRETS = 7
|
||||
SUPPLICANT_DISCONNECT = 8
|
||||
SUPPLICANT_TIMEOUT = 11
|
||||
CONNECTION_REMOVED = 38
|
||||
USER_REQUESTED = 39
|
||||
SSID_NOT_FOUND = 53
|
||||
NEW_ACTIVATION = 60
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# 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,134 @@
|
||||
import math
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from openpilot.system.ui.lib.application import gui_app, MouseEvent
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
# Scroll constants for smooth scrolling behavior
|
||||
MOUSE_WHEEL_SCROLL_SPEED = 50
|
||||
BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps
|
||||
MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
|
||||
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
|
||||
DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
class ScrollState(IntEnum):
|
||||
IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia
|
||||
DRAGGING_CONTENT = 1 # User is actively dragging the content
|
||||
|
||||
|
||||
class GuiScrollPanel:
|
||||
def __init__(self):
|
||||
self._scroll_state: ScrollState = ScrollState.IDLE
|
||||
self._last_mouse_y: float = 0.0
|
||||
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection
|
||||
self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
self._last_drag_time: float = 0.0
|
||||
|
||||
def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float:
|
||||
for mouse_event in gui_app.mouse_events:
|
||||
if mouse_event.slot == 0:
|
||||
self._handle_mouse_event(mouse_event, bounds, content)
|
||||
|
||||
self._update_state(bounds, content)
|
||||
|
||||
return float(self._offset_filter_y.x)
|
||||
|
||||
def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle):
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED)
|
||||
|
||||
# Handle mouse wheel
|
||||
self._offset_filter_y.x += rl.get_mouse_wheel_move() * MOUSE_WHEEL_SCROLL_SPEED
|
||||
|
||||
max_scroll_distance = max(0, content.height - bounds.height)
|
||||
if self._scroll_state == ScrollState.IDLE:
|
||||
above_bounds, below_bounds = self._check_bounds(bounds, content)
|
||||
|
||||
# Decay velocity when idle
|
||||
if abs(self._velocity_filter_y.x) > MIN_VELOCITY:
|
||||
# Faster decay if bouncing back from out of bounds
|
||||
friction = math.exp(-BOUNCE_RETURN_RATE * 1 / gui_app.target_fps)
|
||||
self._velocity_filter_y.x *= friction ** 2 if (above_bounds or below_bounds) else friction
|
||||
else:
|
||||
self._velocity_filter_y.x = 0.0
|
||||
|
||||
if above_bounds or below_bounds:
|
||||
if above_bounds:
|
||||
self._offset_filter_y.update(0)
|
||||
else:
|
||||
self._offset_filter_y.update(-max_scroll_distance)
|
||||
|
||||
self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps
|
||||
|
||||
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
|
||||
# Mouse not moving, decay velocity
|
||||
if not len(gui_app.mouse_events):
|
||||
self._velocity_filter_y.update(0.0)
|
||||
|
||||
# Settle to exact bounds
|
||||
if abs(self._offset_filter_y.x) < 1e-2:
|
||||
self._offset_filter_y.x = 0.0
|
||||
elif abs(self._offset_filter_y.x + max_scroll_distance) < 1e-2:
|
||||
self._offset_filter_y.x = -max_scroll_distance
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle):
|
||||
if self._scroll_state == ScrollState.IDLE:
|
||||
if rl.check_collision_point_rec(mouse_event.pos, bounds):
|
||||
if mouse_event.left_pressed:
|
||||
self._start_mouse_y = mouse_event.pos.y
|
||||
# Interrupt scrolling with new drag
|
||||
# TODO: stop scrolling with any tap, need to fix is_touch_valid
|
||||
if abs(self._velocity_filter_y.x) > MIN_VELOCITY_FOR_CLICKING:
|
||||
self._scroll_state = ScrollState.DRAGGING_CONTENT
|
||||
# Start velocity at initial measurement for more immediate response
|
||||
self._velocity_filter_y.initialized = False
|
||||
|
||||
if mouse_event.left_down:
|
||||
if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD:
|
||||
self._scroll_state = ScrollState.DRAGGING_CONTENT
|
||||
# Start velocity at initial measurement for more immediate response
|
||||
self._velocity_filter_y.initialized = False
|
||||
|
||||
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
|
||||
if mouse_event.left_released:
|
||||
self._scroll_state = ScrollState.IDLE
|
||||
else:
|
||||
delta_y = mouse_event.pos.y - self._last_mouse_y
|
||||
above_bounds, below_bounds = self._check_bounds(bounds, content)
|
||||
# Rubber banding effect when out of bands
|
||||
if above_bounds or below_bounds:
|
||||
delta_y /= 3
|
||||
|
||||
self._offset_filter_y.x += delta_y
|
||||
|
||||
# Track velocity for inertia
|
||||
dt = mouse_event.t - self._last_drag_time
|
||||
if dt > 0:
|
||||
drag_velocity = delta_y / dt
|
||||
self._velocity_filter_y.update(drag_velocity)
|
||||
|
||||
# TODO: just store last mouse event!
|
||||
self._last_drag_time = mouse_event.t
|
||||
self._last_mouse_y = mouse_event.pos.y
|
||||
|
||||
def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]:
|
||||
max_scroll_distance = max(0, content.height - bounds.height)
|
||||
above_bounds = self._offset_filter_y.x > 0
|
||||
below_bounds = self._offset_filter_y.x < -max_scroll_distance
|
||||
return above_bounds, below_bounds
|
||||
|
||||
def is_touch_valid(self):
|
||||
return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING
|
||||
|
||||
def set_offset(self, position: float) -> None:
|
||||
self._offset_filter_y.x = position
|
||||
self._velocity_filter_y.x = 0.0
|
||||
self._scroll_state = ScrollState.IDLE
|
||||
|
||||
@property
|
||||
def offset(self) -> float:
|
||||
return float(self._offset_filter_y.x)
|
||||
@@ -0,0 +1,254 @@
|
||||
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"
|
||||
|
||||
|
||||
# Weights older (steadier) velocity samples more heavily on release.
|
||||
# Finger-lift samples are noisy; trusting earlier samples gives consistent fling velocity.
|
||||
# Reverse-engineered from iOS UIScrollView (tuned at 120Hz touch) by Flutter team:
|
||||
# https://github.com/flutter/flutter/pull/60501
|
||||
# 3 samples ≈ 25ms at 120Hz (iOS) / ~21ms at 140Hz (comma). Scale if touch rate changes.
|
||||
def weighted_velocity(buffer: deque) -> float:
|
||||
if len(buffer) >= 3:
|
||||
return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05
|
||||
elif len(buffer) == 2:
|
||||
return buffer[-2] * 0.7 + buffer[-1] * 0.3
|
||||
elif len(buffer) == 1:
|
||||
return buffer[-1]
|
||||
return 0.0
|
||||
|
||||
|
||||
# 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."""
|
||||
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
|
||||
|
||||
if self._state == ScrollState.STEADY:
|
||||
# if we find ourselves out of bounds, scroll back in (from external layout dimension changes, etc.)
|
||||
if self.get_offset() > max_offset or self.get_offset() < min_offset:
|
||||
self._state = ScrollState.AUTO_SCROLL
|
||||
|
||||
elif self._state == ScrollState.AUTO_SCROLL:
|
||||
# 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.
|
||||
# Catches two cases: 1) swipe, stop finger, then lift (stale high velocity in buffer)
|
||||
# 2) dirty finger lift where finger rotates/slides producing spurious velocity spike.
|
||||
# TODO: this heuristic false-positives on fast swipes because 140Hz touch polling
|
||||
# jitter causes velocity to oscillate (not real deceleration). Better approaches:
|
||||
# - Use evdev kernel timestamps to eliminate velocity oscillation at the source
|
||||
# - Replace with a time-since-last-event check (40ms timeout) for swipe-stop-lift
|
||||
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
|
||||
|
||||
self._velocity = weighted_velocity(self._velocity_buffer)
|
||||
|
||||
# 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,906 @@
|
||||
"""Tests for WifiManager._handle_state_change.
|
||||
|
||||
Tests the state machine in isolation by constructing a WifiManager with mocked
|
||||
DBus, then calling _handle_state_change directly with NM state transitions.
|
||||
"""
|
||||
import pytest
|
||||
from jeepney.low_level import MessageType
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus
|
||||
|
||||
|
||||
def _make_wm(mocker: MockerFixture, connections=None):
|
||||
"""Create a WifiManager with only the fields _handle_state_change touches."""
|
||||
mocker.patch.object(WifiManager, '_initialize')
|
||||
wm = WifiManager.__new__(WifiManager)
|
||||
wm._exit = True # prevent stop() from doing anything in __del__
|
||||
wm._conn_monitor = mocker.MagicMock()
|
||||
wm._connections = dict(connections or {})
|
||||
wm._wifi_state = WifiState()
|
||||
wm._user_epoch = 0
|
||||
wm._callback_queue = []
|
||||
wm._need_auth = []
|
||||
wm._activated = []
|
||||
wm._update_networks = mocker.MagicMock()
|
||||
wm._update_active_connection_info = mocker.MagicMock()
|
||||
wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None))
|
||||
return wm
|
||||
|
||||
|
||||
def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN,
|
||||
reason: int = NMDeviceStateReason.NONE) -> None:
|
||||
"""Feed a state change into the handler."""
|
||||
wm._handle_state_change(new_state, prev_state, reason)
|
||||
|
||||
|
||||
def fire_wpa_connect(wm: WifiManager) -> None:
|
||||
"""WPA handshake then IP negotiation through ACTIVATED, as seen on device."""
|
||||
fire(wm, NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.IP_CONFIG)
|
||||
fire(wm, NMDeviceState.IP_CHECK)
|
||||
fire(wm, NMDeviceState.SECONDARIES)
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDisconnected:
|
||||
def test_generic_disconnect_clears_state(self, mocker):
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
wm._update_networks.assert_not_called()
|
||||
|
||||
def test_new_activation_is_noop(self, mocker):
|
||||
"""NEW_ACTIVATION means NM is about to connect to another network — don't clear."""
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
|
||||
assert wm._wifi_state.ssid == "OldNet"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_connection_removed_keeps_other_connecting(self, mocker):
|
||||
"""Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B."""
|
||||
wm = _make_wm(mocker, connections={"B": "/path/B"})
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_connection_removed_clears_when_forgotten(self, mocker):
|
||||
"""Forget A: A is no longer in _connections, so state should clear."""
|
||||
wm = _make_wm(mocker, connections={})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
|
||||
class TestDeactivating:
|
||||
def test_deactivating_noop_for_non_connection_removed(self, mocker):
|
||||
"""DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op."""
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
|
||||
|
||||
assert wm._wifi_state.ssid == "Net"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
@pytest.mark.parametrize("status, expected_clears", [
|
||||
(ConnectStatus.CONNECTED, True),
|
||||
(ConnectStatus.CONNECTING, False),
|
||||
])
|
||||
def test_deactivating_connection_removed(self, mocker, status, expected_clears):
|
||||
"""DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING.
|
||||
|
||||
CONNECTED: forgetting the current network. The forgotten callback fires between
|
||||
DEACTIVATING and DISCONNECTED — must clear here so the UI doesn't flash "connected"
|
||||
after the eager _network_forgetting flag resets.
|
||||
|
||||
CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal,
|
||||
but B's CONNECTING state must be preserved.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"B": "/path/B"})
|
||||
wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
|
||||
if expected_clears:
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
else:
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
|
||||
class TestPrepareConfig:
|
||||
def test_user_initiated_skips_dbus_lookup(self, mocker):
|
||||
"""User called _set_connecting('B') — PREPARE must not overwrite via DBus.
|
||||
|
||||
Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's
|
||||
stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting"
|
||||
indicator briefly jump to the wrong network row then back.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._set_connecting("B")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/A", {})
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
wm._get_active_wifi_connection.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG])
|
||||
def test_auto_connect_looks_up_ssid(self, mocker, state):
|
||||
"""Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM."""
|
||||
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
|
||||
|
||||
fire(wm, state)
|
||||
|
||||
assert wm._wifi_state.ssid == "AutoNet"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_auto_connect_dbus_fails(self, mocker):
|
||||
"""Auto-connection but DBus returns None: ssid stays None, status CONNECTING."""
|
||||
wm = _make_wm(mocker)
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_auto_connect_conn_path_not_in_connections(self, mocker):
|
||||
"""DBus returns a conn_path that doesn't match any known connection."""
|
||||
wm = _make_wm(mocker, connections={"Other": "/path/other"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/unknown", {})
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
|
||||
class TestNeedAuth:
|
||||
def test_wrong_password_fires_callback(self, mocker):
|
||||
"""NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password."""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("SecNet")
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("SecNet")
|
||||
|
||||
def test_failed_no_secrets_fires_callback(self, mocker):
|
||||
"""FAILED+NO_SECRETS = wrong password (weak/gone network).
|
||||
|
||||
Confirmed on device: also fires when a hotspot turns off during connection.
|
||||
NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS
|
||||
rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows
|
||||
"wrong password" — a false positive, but same signal path.
|
||||
|
||||
Real device sequence (new connection, hotspot turned off immediately):
|
||||
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("WeakNet")
|
||||
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("WeakNet")
|
||||
|
||||
def test_need_auth_then_failed_no_double_fire(self, mocker):
|
||||
"""Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back.
|
||||
|
||||
The first clears ssid, so the second must not fire a duplicate callback.
|
||||
Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) → FAILED(NEED_AUTH, NO_SECRETS)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("BadPass")
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
|
||||
reason=NMDeviceStateReason.NO_SECRETS)
|
||||
assert len(wm._callback_queue) == 1 # no duplicate
|
||||
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("BadPass")
|
||||
|
||||
def test_no_ssid_no_callback(self, mocker):
|
||||
"""If ssid is None when NEED_AUTH fires, no callback enqueued."""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
def test_interrupted_auth_ignored(self, mocker):
|
||||
"""Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback.
|
||||
|
||||
Reproduced on device: rapidly switching between two saved networks can trigger a
|
||||
rare false "wrong password" dialog for the previous network, even though both have
|
||||
correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG).
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._set_connecting("A")
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
|
||||
class TestPassthroughStates:
|
||||
"""NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops."""
|
||||
|
||||
@pytest.mark.parametrize("state", [
|
||||
NMDeviceState.NEED_AUTH,
|
||||
NMDeviceState.IP_CONFIG,
|
||||
NMDeviceState.IP_CHECK,
|
||||
NMDeviceState.SECONDARIES,
|
||||
NMDeviceState.FAILED,
|
||||
])
|
||||
def test_passthrough_is_noop(self, mocker, state):
|
||||
wm = _make_wm(mocker)
|
||||
wm._set_connecting("Net")
|
||||
|
||||
fire(wm, state, reason=NMDeviceStateReason.NONE)
|
||||
|
||||
assert wm._wifi_state.ssid == "Net"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
|
||||
class TestActivated:
|
||||
def test_sets_connected(self, mocker):
|
||||
"""ACTIVATED sets status to CONNECTED and fires callback."""
|
||||
wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(activated=cb)
|
||||
wm._set_connecting("MyNet")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/mynet", {})
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "MyNet"
|
||||
assert len(wm._callback_queue) == 1
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once()
|
||||
|
||||
def test_conn_path_none_still_connected(self, mocker):
|
||||
"""ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged."""
|
||||
wm = _make_wm(mocker)
|
||||
wm._set_connecting("MyNet")
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "MyNet"
|
||||
|
||||
def test_activated_side_effects(self, mocker):
|
||||
"""ACTIVATED persists the volatile connection to disk and updates active connection info."""
|
||||
wm = _make_wm(mocker, connections={"Net": "/path/net"})
|
||||
wm._set_connecting("Net")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/net", {})
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
wm._conn_monitor.send_and_get_reply.assert_called_once()
|
||||
wm._update_active_connection_info.assert_called_once()
|
||||
wm._update_networks.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread.
|
||||
# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler.
|
||||
# The epoch counter detects that a user action occurred during the slow DBus call
|
||||
# and discards the stale update.
|
||||
# ---------------------------------------------------------------------------
|
||||
# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard
|
||||
# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED
|
||||
# guard) shrink these race windows significantly. The epoch counter closes the
|
||||
# remaining gaps.
|
||||
|
||||
class TestThreadRaces:
|
||||
def test_prepare_race_user_tap_during_dbus(self, mocker):
|
||||
"""User taps B while PREPARE's DBus call is in flight for auto-connect.
|
||||
|
||||
Monitor thread reads wifi_state (ssid=None), starts DBus call.
|
||||
Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
|
||||
def user_taps_b_during_dbus(*args, **kwargs):
|
||||
wm._set_connecting("B")
|
||||
return ("/path/A", {})
|
||||
|
||||
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_activated_race_user_tap_during_dbus(self, mocker):
|
||||
"""User taps B right as A finishes connecting (ACTIVATED handler running).
|
||||
|
||||
Monitor thread reads wifi_state (A, CONNECTING), starts DBus call.
|
||||
Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._set_connecting("A")
|
||||
|
||||
def user_taps_b_during_dbus(*args, **kwargs):
|
||||
wm._set_connecting("B")
|
||||
return ("/path/A", {})
|
||||
|
||||
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
|
||||
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
def test_init_wifi_state_race_user_tap_during_dbus(self, mocker):
|
||||
"""User taps B while _init_wifi_state's DBus calls are in flight.
|
||||
|
||||
_init_wifi_state runs from set_active(True) or worker error paths. It does
|
||||
2 DBus calls (device State property + _get_active_wifi_connection) then
|
||||
unconditionally writes _wifi_state. If the user taps a network during those
|
||||
calls, _set_connecting("B") is overwritten with stale NM ground truth.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._wifi_device = "/dev/wifi0"
|
||||
wm._router_main = mocker.MagicMock()
|
||||
|
||||
state_reply = mocker.MagicMock()
|
||||
state_reply.body = [('u', NMDeviceState.ACTIVATED)]
|
||||
wm._router_main.send_and_get_reply.return_value = state_reply
|
||||
|
||||
def user_taps_b_during_dbus(*args, **kwargs):
|
||||
wm._set_connecting("B")
|
||||
return ("/path/A", {})
|
||||
|
||||
wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus
|
||||
|
||||
wm._init_wifi_state()
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full sequences (NM signal order from real devices)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFullSequences:
|
||||
def test_normal_connect(self, mocker):
|
||||
"""User connects to saved network: full happy path.
|
||||
|
||||
Real device sequence (switching from another connected network):
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"Home": "/path/home"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/home", {})
|
||||
|
||||
wm._set_connecting("Home")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.IP_CONFIG)
|
||||
fire(wm, NMDeviceState.IP_CHECK)
|
||||
fire(wm, NMDeviceState.SECONDARIES)
|
||||
fire(wm, NMDeviceState.ACTIVATED)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "Home"
|
||||
|
||||
def test_wrong_password_then_retry(self, mocker):
|
||||
"""Wrong password → NEED_AUTH → FAILED → NM auto-reconnects to saved network.
|
||||
|
||||
Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi.
|
||||
|
||||
Real device sequence (switching from a connected network):
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) ← WPA handshake
|
||||
→ PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password
|
||||
→ FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up
|
||||
→ DISCONNECTED(FAILED, NONE)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED ← auto-reconnect to other saved network
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"Sec": "/path/sec"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
wm._set_connecting("Sec")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
# FAILED(NO_SECRETS) follows but ssid is already cleared — no double-fire
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS)
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
|
||||
|
||||
# Retry
|
||||
wm._callback_queue.clear()
|
||||
wm._set_connecting("Sec")
|
||||
wm._get_active_wifi_connection.return_value = ("/path/sec", {})
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_switch_saved_networks(self, mocker):
|
||||
"""Switch from A to B (both saved): NM signal sequence from real device.
|
||||
|
||||
Real device sequence:
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
|
||||
def test_rapid_switch_no_false_wrong_password(self, mocker):
|
||||
"""Switch A→B quickly: A's interrupted NEED_AUTH must NOT show wrong password.
|
||||
|
||||
NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly
|
||||
switching between networks with wrong/new passwords. Less common when switching between
|
||||
saved networks with correct passwords. Not guaranteed — some switches skip it and go
|
||||
straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED
|
||||
for stale signals, so the prev_state guard reliably distinguishes them.
|
||||
|
||||
Worst-case signal sequence this protects against:
|
||||
DEACTIVATING(NEW_ACTIVATION) → DISCONNECTED(NEW_ACTIVATION)
|
||||
→ NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure
|
||||
→ PREPARE → CONFIG → ... → ACTIVATED ← B connects
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
|
||||
wm._set_connecting("B")
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.NEW_ACTIVATION)
|
||||
fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED,
|
||||
reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT)
|
||||
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
assert len(wm._callback_queue) == 0
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_forget_while_connecting(self, mocker):
|
||||
"""Forget the network we're currently connecting to (not yet ACTIVATED).
|
||||
|
||||
Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot
|
||||
Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward.
|
||||
|
||||
Real device sequence (switching then forgetting mid-connection):
|
||||
DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG
|
||||
→ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
|
||||
→ PREPARE → CONFIG → ... → ACTIVATED ← NM auto-connects to other saved network
|
||||
|
||||
Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is
|
||||
CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state
|
||||
(ssid removed from _connections by ConnectionRemoved), then PREPARE recovers
|
||||
via DBus lookup for the auto-connect.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/other", {})
|
||||
|
||||
wm._set_connecting("A")
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
# User forgets A: ConnectionRemoved processed first, then state changes
|
||||
del wm._connections["A"]
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
# NM auto-connects to another saved network
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
assert wm._wifi_state.ssid == "Other"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "Other"
|
||||
|
||||
def test_forget_connected_network(self, mocker):
|
||||
"""Forget the currently connected network (not switching to another).
|
||||
|
||||
Real device sequence:
|
||||
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
|
||||
|
||||
ConnectionRemoved signal may or may not have been processed before state changes.
|
||||
Either way, state must clear — we're forgetting what we're connected to, not switching.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
# DISCONNECTED follows — harmless since state is already cleared
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
def test_forget_A_connect_B(self, mocker):
|
||||
"""Forget A while connecting to B: full signal sequence.
|
||||
|
||||
Real device sequence:
|
||||
DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG
|
||||
→ IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED
|
||||
|
||||
Signal order:
|
||||
1. User: _set_connecting("B"), forget("A") removes A from _connections
|
||||
2. NewConnection for B arrives → _connections["B"] = ...
|
||||
3. DEACTIVATING(CONNECTION_REMOVED) — no-op
|
||||
4. DISCONNECTED(CONNECTION_REMOVED) — B is in _connections, must not clear
|
||||
5. PREPARE → CONFIG → NEED_AUTH → PREPARE → CONFIG → ... → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
wm._set_connecting("B")
|
||||
del wm._connections["A"]
|
||||
wm._connections["B"] = "/path/B"
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
|
||||
def test_forget_A_connect_B_late_new_connection(self, mocker):
|
||||
"""Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED.
|
||||
|
||||
This is the worst-case race: B isn't in _connections when DISCONNECTED fires,
|
||||
so the guard can't protect it and state clears. PREPARE must recover by doing
|
||||
the DBus lookup (ssid is None at that point).
|
||||
|
||||
Signal order:
|
||||
1. User: _set_connecting("B"), forget("A") removes A from _connections
|
||||
2. DEACTIVATING(CONNECTION_REMOVED) — B NOT in _connections, should be no-op
|
||||
3. DISCONNECTED(CONNECTION_REMOVED) — B STILL NOT in _connections, clears state
|
||||
4. NewConnection for B arrives late → _connections["B"] = ...
|
||||
5. PREPARE (ssid=None, so DBus lookup recovers) → CONFIG → ACTIVATED
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
|
||||
wm._set_connecting("B")
|
||||
del wm._connections["A"]
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING,
|
||||
reason=NMDeviceStateReason.CONNECTION_REMOVED)
|
||||
# B not in _connections yet, so state clears — this is the known edge case
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
# NewConnection arrives late
|
||||
wm._connections["B"] = "/path/B"
|
||||
wm._get_active_wifi_connection.return_value = ("/path/B", {})
|
||||
|
||||
# PREPARE recovers: ssid is None so it looks up from DBus
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "B"
|
||||
|
||||
def test_auto_connect(self, mocker):
|
||||
"""NM auto-connects (no user action, ssid starts None)."""
|
||||
wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"})
|
||||
wm._get_active_wifi_connection.return_value = ("/path/auto", {})
|
||||
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
assert wm._wifi_state.ssid == "AutoNet"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire_wpa_connect(wm)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
assert wm._wifi_state.ssid == "AutoNet"
|
||||
|
||||
def test_network_lost_during_connection(self, mocker):
|
||||
"""Hotspot turned off while connecting (before ACTIVATED).
|
||||
|
||||
Confirmed on device: started new connection to Shane's iPhone, immediately
|
||||
turned off the hotspot. NM can't complete WPA handshake and reports
|
||||
FAILED(NO_SECRETS) — same signal as wrong password (false positive).
|
||||
|
||||
Real device sequence:
|
||||
PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE)
|
||||
|
||||
Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the
|
||||
normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP
|
||||
vanished mid-handshake.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
wm._set_connecting("Hotspot")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE)
|
||||
fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
# Second NEED_AUTH(CONFIG, NONE) — NM retries handshake, AP vanishing
|
||||
fire(wm, NMDeviceState.NEED_AUTH)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING
|
||||
|
||||
# NM gives up — reports NO_SECRETS (same as wrong password)
|
||||
fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH,
|
||||
reason=NMDeviceStateReason.NO_SECRETS)
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert len(wm._callback_queue) == 1
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
wm.process_callbacks()
|
||||
cb.assert_called_once_with("Hotspot")
|
||||
|
||||
@pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI")
|
||||
def test_ssid_not_found(self, mocker):
|
||||
"""Network drops off while connected — hotspot turned off.
|
||||
|
||||
NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found"
|
||||
|
||||
Confirmed on device: connected to Shane's iPhone, then turned off the hotspot.
|
||||
No DEACTIVATING fires — NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND).
|
||||
NM retries connecting (PREPARE → CONFIG → ... → FAILED(CONFIG, SSID_NOT_FOUND))
|
||||
before finally giving up with DISCONNECTED.
|
||||
|
||||
NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically
|
||||
produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets).
|
||||
|
||||
Real device sequence (hotspot turned off while connected):
|
||||
FAILED(ACTIVATED, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
|
||||
→ PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG
|
||||
→ FAILED(CONFIG, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE)
|
||||
|
||||
The UI error callback mechanism is intentionally deferred — for now just clear state.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"})
|
||||
cb = mocker.MagicMock()
|
||||
wm.add_callbacks(need_auth=cb)
|
||||
|
||||
wm._set_connecting("GoneNet")
|
||||
fire(wm, NMDeviceState.PREPARE)
|
||||
fire(wm, NMDeviceState.CONFIG)
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND)
|
||||
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
assert wm._wifi_state.ssid is None
|
||||
|
||||
def test_failed_then_disconnected_clears_state(self, mocker):
|
||||
"""After FAILED, NM always transitions to DISCONNECTED to clean up.
|
||||
|
||||
NM docs: FAILED (120) = "failed to connect, cleaning up the connection request"
|
||||
Full sequence: ... → FAILED(reason) → DISCONNECTED(NONE)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
wm._set_connecting("Net")
|
||||
|
||||
fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE)
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op
|
||||
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE)
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
def test_user_requested_disconnect(self, mocker):
|
||||
"""User explicitly disconnects from the network.
|
||||
|
||||
NM docs: USER_REQUESTED (39) = "Device disconnected by user or client"
|
||||
Expected sequence: DEACTIVATING(USER_REQUESTED) → DISCONNECTED(USER_REQUESTED)
|
||||
"""
|
||||
wm = _make_wm(mocker)
|
||||
wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED)
|
||||
|
||||
fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED)
|
||||
fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED)
|
||||
|
||||
assert wm._wifi_state.ssid is None
|
||||
assert wm._wifi_state.status == ConnectStatus.DISCONNECTED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker error recovery: DBus errors in activate/connect re-sync with NM
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verified on device: when ActivateConnection returns UnknownConnection error,
|
||||
# NM emits no state signals. The worker error path is the only recovery point.
|
||||
|
||||
class TestWorkerErrorRecovery:
|
||||
"""Worker threads re-sync with NM via _init_wifi_state on DBus errors,
|
||||
preserving actual NM state instead of blindly clearing to DISCONNECTED."""
|
||||
|
||||
def _mock_init_restores(self, wm, mocker, ssid, status):
|
||||
"""Replace _init_wifi_state with a mock that simulates NM reporting the given state."""
|
||||
mock = mocker.MagicMock(
|
||||
side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status))
|
||||
)
|
||||
wm._init_wifi_state = mock
|
||||
return mock
|
||||
|
||||
def test_activate_dbus_error_resyncs(self, mocker):
|
||||
"""ActivateConnection returns DBus error while A is connected.
|
||||
NM rejects the request — no state signals emitted. Worker must re-read NM
|
||||
state to discover A is still connected, not clear to DISCONNECTED.
|
||||
"""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"})
|
||||
wm._wifi_device = "/dev/wifi0"
|
||||
wm._nm = mocker.MagicMock()
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._router_main = mocker.MagicMock()
|
||||
|
||||
error_reply = mocker.MagicMock()
|
||||
error_reply.header.message_type = MessageType.error
|
||||
wm._router_main.send_and_get_reply.return_value = error_reply
|
||||
|
||||
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
|
||||
|
||||
wm.activate_connection("B", block=True)
|
||||
|
||||
mock_init.assert_called_once()
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
def test_connect_to_network_dbus_error_resyncs(self, mocker):
|
||||
"""AddAndActivateConnection2 returns DBus error while A is connected."""
|
||||
wm = _make_wm(mocker, connections={"A": "/path/A"})
|
||||
wm._wifi_device = "/dev/wifi0"
|
||||
wm._nm = mocker.MagicMock()
|
||||
wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED)
|
||||
wm._router_main = mocker.MagicMock()
|
||||
wm._forgotten = []
|
||||
|
||||
error_reply = mocker.MagicMock()
|
||||
error_reply.header.message_type = MessageType.error
|
||||
wm._router_main.send_and_get_reply.return_value = error_reply
|
||||
|
||||
mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED)
|
||||
|
||||
# Run worker thread synchronously
|
||||
workers = []
|
||||
mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread',
|
||||
side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})())
|
||||
|
||||
wm.connect_to_network("B", "password123")
|
||||
workers[-1]()
|
||||
|
||||
mock_init.assert_called_once()
|
||||
assert wm._wifi_state.ssid == "A"
|
||||
assert wm._wifi_state.status == ConnectStatus.CONNECTED
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from enum import IntEnum
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.hardware import HARDWARE, PC
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.system.ui.mici_setup import GreyBigButton, FailedPage
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationCircleButton
|
||||
|
||||
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
|
||||
TAP_RESET = 2 # user initiated a factory reset by tapping the screen during boot
|
||||
|
||||
|
||||
class ResetFailedPage(FailedPage):
|
||||
def __init__(self):
|
||||
super().__init__(None, "reset failed", "reboot to try again", icon="icons_mici/setup/reset_failed.png")
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._nav_bar._alpha = 0.0 # not dismissable
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class ResettingPage(BigDialog):
|
||||
DOT_STEP = 0.6
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("resetting device", "this may take up to\na minute...",
|
||||
gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64))
|
||||
self._show_time = 0.0
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._nav_bar._alpha = 0.0 # not dismissable
|
||||
self._show_time = rl.get_time()
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
def _render(self, _):
|
||||
t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2)
|
||||
dots = "." * min(int(t / (self.DOT_STEP / 4)), 3)
|
||||
self._card.set_value(f"this may take up to\na minute{dots}")
|
||||
super()._render(_)
|
||||
|
||||
|
||||
class Reset(Scroller):
|
||||
def __init__(self, mode):
|
||||
super().__init__()
|
||||
self._mode = mode
|
||||
self._previous_active_widget = None
|
||||
self._reset_failed = False
|
||||
self._timeout_st = time.monotonic()
|
||||
|
||||
self._resetting_page = ResettingPage()
|
||||
self._reset_failed_page = ResetFailedPage()
|
||||
|
||||
self._reset_button = BigConfirmationCircleButton("reset &\nerase", gui_app.texture("icons_mici/settings/device/uninstall.png", 70, 70),
|
||||
self._start_reset, exit_on_confirm=False, red=True)
|
||||
self._cancel_button = BigConfirmationCircleButton("cancel", gui_app.texture("icons_mici/setup/cancel.png", 64, 64),
|
||||
gui_app.request_close, exit_on_confirm=False)
|
||||
self._reboot_button = BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
|
||||
HARDWARE.reboot, exit_on_confirm=False)
|
||||
|
||||
# show reboot button if in recover mode
|
||||
self._cancel_button.set_visible(mode != ResetMode.RECOVER)
|
||||
self._reboot_button.set_visible(mode == ResetMode.RECOVER)
|
||||
|
||||
main_card = GreyBigButton("factory reset", "resetting erases\nall user content & data",
|
||||
gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64))
|
||||
self._scroller.add_widget(main_card)
|
||||
|
||||
if mode != ResetMode.USER_RESET:
|
||||
self._scroller.add_widget(GreyBigButton("", "Resetting erases all user content & data."))
|
||||
if mode == ResetMode.RECOVER:
|
||||
main_card.set_value("user data partition\ncould not be mounted")
|
||||
elif mode == ResetMode.TAP_RESET:
|
||||
main_card.set_value("reset triggered by\ntapping the screen")
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"),
|
||||
self._cancel_button,
|
||||
self._reboot_button,
|
||||
self._reset_button,
|
||||
])
|
||||
|
||||
gui_app.add_nav_stack_tick(self._nav_stack_tick)
|
||||
|
||||
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_failed = True
|
||||
|
||||
def _start_reset(self):
|
||||
def do_erase_thread():
|
||||
threading.Thread(target=self._do_erase, daemon=True).start()
|
||||
|
||||
self._resetting_page.set_shown_callback(do_erase_thread)
|
||||
gui_app.push_widget(self._resetting_page)
|
||||
|
||||
def _nav_stack_tick(self):
|
||||
if self._reset_failed:
|
||||
self._reset_failed = False
|
||||
gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._reset_failed_page))
|
||||
|
||||
active_widget = gui_app.get_active_widget()
|
||||
if active_widget != self._previous_active_widget:
|
||||
self._previous_active_widget = active_widget
|
||||
self._timeout_st = time.monotonic()
|
||||
elif self._mode != ResetMode.RECOVER and active_widget != self._resetting_page and (time.monotonic() - self._timeout_st) > TIMEOUT:
|
||||
exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
mode = ResetMode.USER_RESET
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == '--recover':
|
||||
mode = ResetMode.RECOVER
|
||||
elif sys.argv[1] == '--tap-reset':
|
||||
mode = ResetMode.TAP_RESET
|
||||
|
||||
gui_app.init_window("System Reset")
|
||||
reset = Reset(mode)
|
||||
gui_app.push_widget(reset)
|
||||
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+593
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from urllib.parse import urlparse
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from cereal import log
|
||||
from openpilot.common.filter_simple import BounceFilter
|
||||
from openpilot.system.hardware import HARDWARE, TICI
|
||||
from openpilot.common.realtime import config_realtime_process, set_core_affinity
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.time_helpers import system_time_valid
|
||||
from openpilot.common.utils import run_cmd
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.widgets.scroller import Scroller, NavScroller, ITEM_SPACING
|
||||
from openpilot.system.ui.widgets.slider import LargerSlider
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
OPENPILOT_URL = "https://openpilot.comma.ai"
|
||||
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
|
||||
|
||||
INSTALLER_DESTINATION_PATH = "/tmp/installer"
|
||||
INSTALLER_URL_PATH = "/tmp/installer_url"
|
||||
|
||||
|
||||
class NetworkConnectivityMonitor:
|
||||
def __init__(self, should_check: Callable[[], bool] | None = None):
|
||||
self.network_connected = threading.Event()
|
||||
self.wifi_connected = threading.Event()
|
||||
self.recheck_event = threading.Event()
|
||||
self._should_check = should_check or (lambda: True)
|
||||
self._stop_event = threading.Event()
|
||||
self._last_timesyncd_restart = 0.0
|
||||
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 invalidate(self):
|
||||
self.recheck_event.set()
|
||||
self.reset()
|
||||
|
||||
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=2.0)
|
||||
|
||||
# Discard stale result if invalidated during request
|
||||
if self.recheck_event.is_set():
|
||||
self.recheck_event.clear()
|
||||
continue
|
||||
|
||||
self.network_connected.set()
|
||||
if HARDWARE.get_network_type() == NetworkType.wifi:
|
||||
self.wifi_connected.set()
|
||||
except urllib.error.URLError as e:
|
||||
if (isinstance(e.reason, ssl.SSLCertVerificationError) and
|
||||
not system_time_valid() and
|
||||
time.monotonic() - self._last_timesyncd_restart > 5):
|
||||
self._last_timesyncd_restart = time.monotonic()
|
||||
run_cmd(["sudo", "systemctl", "restart", "systemd-timesyncd"])
|
||||
self.reset()
|
||||
except Exception:
|
||||
self.reset()
|
||||
else:
|
||||
self.reset()
|
||||
|
||||
if self._stop_event.wait(timeout=1.0):
|
||||
break
|
||||
|
||||
|
||||
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/start_button.png", 500, 224, keep_aspect_ratio=False)
|
||||
self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/start_button_pressed.png", 500, 224, keep_aspect_ratio=False)
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._click_delay = 0.075
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
scale = self._scale_filter.update(1.07 if self.is_pressed else 1.0)
|
||||
base_draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
|
||||
base_draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
|
||||
draw_x = base_draw_x + (self._start_bg_txt.width * (1 - scale)) / 2
|
||||
draw_y = base_draw_y + (self._start_bg_txt.height * (1 - scale)) / 2
|
||||
texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
|
||||
rl.draw_texture_ex(texture, (draw_x, draw_y), 0, scale, rl.WHITE)
|
||||
|
||||
self._title.render(rl.Rectangle(rect.x, rect.y + (draw_y - base_draw_y), rect.width, rect.height))
|
||||
|
||||
|
||||
class SoftwareSelectionPage(NavWidget):
|
||||
def __init__(self, use_openpilot_callback: Callable,
|
||||
use_custom_software_callback: Callable):
|
||||
super().__init__()
|
||||
|
||||
self._openpilot_slider = self._child(LargerSlider("slide to install\nopenpilot", use_openpilot_callback))
|
||||
self._openpilot_slider.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
self._custom_software_slider = self._child(LargerSlider("slide to install\ncustom software", use_custom_software_callback, green=False, shimmer_offset=0.4))
|
||||
self._custom_software_slider.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._nav_bar._alpha = 0.0
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self.is_dismissing:
|
||||
self.reset()
|
||||
|
||||
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 CustomSoftwareWarningPage(NavScroller):
|
||||
def __init__(self, continue_callback: Callable, back_callback: Callable):
|
||||
super().__init__()
|
||||
self.set_back_callback(back_callback)
|
||||
|
||||
self._continue_button = BigPillButton("next")
|
||||
self._continue_button.set_click_callback(continue_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("caution: installing\n3rd party software", "swipe down to go back",
|
||||
gui_app.texture("icons_mici/setup/warning.png", 64, 58)),
|
||||
GreyBigButton("", "• It has not been tested by comma."),
|
||||
GreyBigButton("", "• It may not comply with safety standards."),
|
||||
GreyBigButton("", "• It may damage your device and/or vehicle."),
|
||||
GreyBigButton("how to restore to a\nfactory state later", "https://flash.comma.ai",
|
||||
gui_app.texture("icons_mici/setup/restore.png", 64, 64)),
|
||||
self._continue_button,
|
||||
])
|
||||
|
||||
|
||||
# TODO: unifi with updater's progress page
|
||||
class DownloadingPage(NavWidget):
|
||||
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("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
|
||||
font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
|
||||
self._progress = 0
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._nav_bar._alpha = 0.0 # not dismissable
|
||||
self.set_progress(0)
|
||||
|
||||
def set_progress(self, progress: int):
|
||||
self._progress = progress
|
||||
self._progress_label.set_text(f"{progress}%")
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rec(rect, rl.BLACK)
|
||||
self._title_label.render(rl.Rectangle(
|
||||
rect.x + 12,
|
||||
rect.y + 2,
|
||||
rect.width,
|
||||
64,
|
||||
))
|
||||
|
||||
self._progress_label.render(rl.Rectangle(
|
||||
rect.x + 12,
|
||||
rect.y + 18,
|
||||
rect.width,
|
||||
rect.height,
|
||||
))
|
||||
|
||||
|
||||
class FailedPage(NavScroller):
|
||||
def __init__(self, retry_callback: Callable | None, title: str = "download failed",
|
||||
description: str | None = None, icon: str = "icons_mici/setup/warning.png"):
|
||||
super().__init__()
|
||||
self.set_back_callback(retry_callback)
|
||||
|
||||
self._reason_card = GreyBigButton("", "")
|
||||
self._reason_card.set_visible(False)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton(title, description or "swipe down to go\nback and try again",
|
||||
gui_app.texture(icon, 64, 58)),
|
||||
self._reason_card,
|
||||
BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70),
|
||||
HARDWARE.reboot, exit_on_confirm=False),
|
||||
])
|
||||
|
||||
def set_reason(self, reason: str):
|
||||
if reason:
|
||||
self._reason_card.set_value(reason)
|
||||
self._reason_card.set_visible(True)
|
||||
else:
|
||||
self._reason_card.set_visible(False)
|
||||
|
||||
|
||||
class BigPillButton(BigButton):
|
||||
def __init__(self, *args, green: bool = False, disabled_background: bool = False, **kwargs):
|
||||
self._green = green
|
||||
self._disabled_background = disabled_background
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._label.set_font_size(48)
|
||||
self._label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
def _load_images(self):
|
||||
if self._green:
|
||||
self._txt_default_bg = gui_app.texture("icons_mici/setup/start_button.png", 402, 180)
|
||||
self._txt_pressed_bg = gui_app.texture("icons_mici/setup/start_button_pressed.png", 402, 180)
|
||||
else:
|
||||
self._txt_default_bg = gui_app.texture("icons_mici/setup/continue.png", 402, 180)
|
||||
self._txt_pressed_bg = gui_app.texture("icons_mici/setup/continue_pressed.png", 402, 180)
|
||||
self._txt_disabled_bg = gui_app.texture("icons_mici/setup/continue_disabled.png", 402, 180)
|
||||
|
||||
def set_green(self, green: bool):
|
||||
if self._green != green:
|
||||
self._green = green
|
||||
self._load_images()
|
||||
|
||||
def _update_label_layout(self):
|
||||
# Don't change label text size
|
||||
pass
|
||||
|
||||
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
|
||||
txt_bg, btn_x, btn_y, scale = super()._handle_background()
|
||||
|
||||
if self._disabled_background:
|
||||
txt_bg = self._txt_disabled_bg
|
||||
return txt_bg, btn_x, btn_y, scale
|
||||
|
||||
|
||||
class NetworkSetupPageBase(Scroller):
|
||||
def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None],
|
||||
disable_connect_hint: bool = False):
|
||||
super().__init__()
|
||||
|
||||
self._wifi_manager = WifiManager()
|
||||
self._wifi_manager.set_active(True)
|
||||
self._network_monitor = network_monitor
|
||||
self._custom_software = False
|
||||
self._wifi_ui = WifiUIMici(self._wifi_manager)
|
||||
|
||||
self._connect_button = GreyBigButton("connect to\ninternet", "swipe down to go back",
|
||||
gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True))
|
||||
self._connect_button.set_visible(not disable_connect_hint)
|
||||
|
||||
self._wifi_button = WifiNetworkButton(self._wifi_manager)
|
||||
self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
|
||||
|
||||
self._prev_has_internet = False
|
||||
self._prev_wifi_connected = False
|
||||
self._pending_has_internet_scroll: float | None = None # stores time to use as delay
|
||||
self._pending_continue_grow_animation = False
|
||||
self._pending_wifi_grow_animation = False
|
||||
|
||||
def on_waiting_click():
|
||||
offset = (self._wifi_button.rect.x + self._wifi_button.rect.width / 2) - (self._rect.x + self._rect.width / 2)
|
||||
self._scroller.scroll_to(offset, smooth=True, block_interaction=True)
|
||||
# trigger grow when wifi button in view
|
||||
self._pending_wifi_grow_animation = True
|
||||
|
||||
self._waiting_button = BigPillButton("connect to\ncontinue", disabled_background=True)
|
||||
self._waiting_button.set_click_callback(on_waiting_click)
|
||||
self._continue_button = BigPillButton("install openpilot", green=True)
|
||||
self._continue_button.set_click_callback(lambda: continue_callback(self._custom_software))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._connect_button,
|
||||
self._wifi_button,
|
||||
self._continue_button,
|
||||
self._waiting_button,
|
||||
])
|
||||
|
||||
gui_app.add_nav_stack_tick(self._nav_stack_tick)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# make sure we populate strength and ip immediately if already have wifi
|
||||
self._wifi_manager.set_active(True)
|
||||
self._prev_has_internet = self._has_internet
|
||||
self._prev_wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED
|
||||
self._pending_has_internet_scroll = None
|
||||
self._pending_continue_grow_animation = False
|
||||
self._pending_wifi_grow_animation = False
|
||||
|
||||
if self._prev_has_internet or self._prev_wifi_connected:
|
||||
self.set_shown_callback(lambda: self._scroll_to_end_and_grow())
|
||||
|
||||
@property
|
||||
def _has_internet(self) -> bool:
|
||||
network_changing = self._wifi_ui.any_network_forgetting or self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTING
|
||||
if network_changing:
|
||||
self._network_monitor.invalidate()
|
||||
|
||||
has_internet = (self._network_monitor.network_connected.is_set() and
|
||||
not network_changing and
|
||||
not self._network_monitor.recheck_event.is_set())
|
||||
return has_internet
|
||||
|
||||
def _nav_stack_tick(self):
|
||||
# Only run tick when this page or its WiFi UI is on the stack
|
||||
if gui_app.get_active_widget() is not self and not gui_app.widget_in_stack(self._wifi_ui):
|
||||
self._wifi_manager.process_callbacks()
|
||||
return
|
||||
|
||||
# Check network state before processing callbacks so forgetting flag
|
||||
# is still set on the frame the forgotten callback fires
|
||||
has_internet = self._has_internet
|
||||
wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED
|
||||
|
||||
self._continue_button.set_visible(has_internet)
|
||||
self._waiting_button.set_visible(not has_internet)
|
||||
|
||||
# TODO: fire show/hide events on visibility changes
|
||||
if not has_internet:
|
||||
self._pending_continue_grow_animation = False
|
||||
self._waiting_button.set_text("waiting for\ninternet..." if wifi_connected else "connect to\ncontinue")
|
||||
|
||||
self._wifi_manager.process_callbacks()
|
||||
|
||||
# Dismiss WiFi UI and scroll on WiFi connect or internet gain
|
||||
if (has_internet and not self._prev_has_internet) or (wifi_connected and not self._prev_wifi_connected):
|
||||
# TODO: cancel if connect is transient
|
||||
self._pending_has_internet_scroll = rl.get_time()
|
||||
|
||||
self._prev_has_internet = has_internet
|
||||
self._prev_wifi_connected = wifi_connected
|
||||
|
||||
if self._pending_has_internet_scroll is not None:
|
||||
# Scrolls over to continue button, then grows once in view
|
||||
elapsed = rl.get_time() - self._pending_has_internet_scroll
|
||||
if elapsed > 0.7 or gui_app.get_active_widget() is self: # instant scroll + grow if not popping
|
||||
# Animate WifiUi down first before scroll
|
||||
self._pending_has_internet_scroll = None
|
||||
gui_app.pop_widgets_to(self, self._scroll_to_end_and_grow)
|
||||
|
||||
def _scroll_to_end_and_grow(self):
|
||||
self._scroller._layout()
|
||||
end_offset = -(self._scroller.content_size - self._rect.width)
|
||||
remaining = self._scroller.scroll_panel.get_offset() - end_offset
|
||||
self._scroller.scroll_to(remaining, smooth=True, block_interaction=True)
|
||||
self._pending_continue_grow_animation = True
|
||||
|
||||
def set_custom_software(self, custom_software: bool):
|
||||
self._custom_software = custom_software
|
||||
self._continue_button.set_text("install openpilot" if not custom_software else "choose software")
|
||||
self._continue_button.set_green(not custom_software)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if self._pending_continue_grow_animation:
|
||||
btn_right = self._continue_button.rect.x + self._continue_button.rect.width
|
||||
visible_right = self._rect.x + self._rect.width
|
||||
if btn_right < visible_right + 50:
|
||||
self._pending_continue_grow_animation = False
|
||||
self._continue_button.trigger_grow_animation()
|
||||
|
||||
if self._pending_wifi_grow_animation and abs(self._wifi_button.rect.x - ITEM_SPACING) < 50:
|
||||
self._pending_wifi_grow_animation = False
|
||||
self._wifi_button.trigger_grow_animation()
|
||||
|
||||
|
||||
class NetworkSetupPage(NetworkSetupPageBase, NavScroller):
|
||||
def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None],
|
||||
back_callback: Callable[[], None] | None):
|
||||
super().__init__(network_monitor, continue_callback)
|
||||
self.set_back_callback(back_callback)
|
||||
|
||||
|
||||
class Setup(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.download_url = ""
|
||||
self.download_progress = 0
|
||||
self.download_thread = None
|
||||
self._download_failed_reason: str | None = None
|
||||
|
||||
self._network_monitor = NetworkConnectivityMonitor()
|
||||
self._network_monitor.start()
|
||||
|
||||
def getting_started_button_callback():
|
||||
gui_app.push_widget(self._software_selection_page)
|
||||
|
||||
self._start_page = StartPage()
|
||||
self._start_page.set_click_callback(getting_started_button_callback)
|
||||
self._start_page.set_enabled(lambda: self.enabled) # for nav stack
|
||||
|
||||
self._network_setup_page = NetworkSetupPage(self._network_monitor, self._network_setup_continue_callback, self._pop_to_software_selection)
|
||||
|
||||
self._software_selection_page = SoftwareSelectionPage(self._push_network_setup, lambda: gui_app.push_widget(self._custom_software_warning_page))
|
||||
|
||||
self._download_failed_page = FailedPage(self._pop_to_software_selection, icon="icons_mici/setup/red_warning.png")
|
||||
|
||||
self._custom_software_warning_page = CustomSoftwareWarningPage(lambda: self._push_network_setup(True), self._pop_to_software_selection)
|
||||
|
||||
self._downloading_page = DownloadingPage()
|
||||
|
||||
gui_app.add_nav_stack_tick(self._nav_stack_tick)
|
||||
|
||||
def _nav_stack_tick(self):
|
||||
self._downloading_page.set_progress(self.download_progress)
|
||||
|
||||
if self._download_failed_reason is not None:
|
||||
reason = self._download_failed_reason
|
||||
self._download_failed_reason = None
|
||||
self._download_failed_page.set_reason(reason)
|
||||
gui_app.pop_widgets_to(self._software_selection_page, lambda: gui_app.push_widget(self._download_failed_page))
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._start_page.render(rect)
|
||||
|
||||
def close(self):
|
||||
self._network_monitor.stop()
|
||||
|
||||
def _pop_to_software_selection(self):
|
||||
# reset sliders after dismiss completes
|
||||
gui_app.pop_widgets_to(self._software_selection_page, self._software_selection_page.reset)
|
||||
|
||||
def _push_network_setup(self, custom_software: bool = False):
|
||||
# to fire the correct continue callback later
|
||||
self._network_setup_page.set_custom_software(custom_software)
|
||||
gui_app.push_widget(self._network_setup_page)
|
||||
|
||||
def _network_setup_continue_callback(self, custom_software: bool):
|
||||
if not custom_software:
|
||||
self._download(OPENPILOT_URL)
|
||||
else:
|
||||
def handle_keyboard_result(text):
|
||||
url = text.strip()
|
||||
if url:
|
||||
self._download(url)
|
||||
|
||||
keyboard = BigInputDialog("custom software URL...", confirm_callback=handle_keyboard_result, auto_return_to_letters="./")
|
||||
gui_app.push_widget(keyboard)
|
||||
|
||||
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.download_progress = 0
|
||||
|
||||
def start_download():
|
||||
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
|
||||
self.download_thread.start()
|
||||
|
||||
self._downloading_page.set_shown_callback(start_download)
|
||||
gui_app.push_widget(self._downloading_page)
|
||||
|
||||
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_reason = "No custom software found at this URL: " + self.download_url.replace("https://", "", 1)
|
||||
return
|
||||
|
||||
# NOTE: currently unused, for future logging
|
||||
with open(INSTALLER_URL_PATH, "w") as f:
|
||||
f.write(self.download_url)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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:
|
||||
self._download_failed_reason = "Incompatible openpilot version."
|
||||
except Exception:
|
||||
self._download_failed_reason = "Invalid URL: " + self.download_url.replace("https://", "", 1)
|
||||
|
||||
|
||||
def main():
|
||||
config_realtime_process(0, 51)
|
||||
# attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off
|
||||
if TICI:
|
||||
try:
|
||||
set_core_affinity([5])
|
||||
except OSError:
|
||||
cloudlog.exception("Failed to set core affinity for setup process")
|
||||
|
||||
try:
|
||||
gui_app.init_window("Setup")
|
||||
setup = Setup()
|
||||
gui_app.push_widget(setup)
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
setup.close()
|
||||
except Exception as e:
|
||||
print(f"Setup error: {e}")
|
||||
finally:
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.realtime import config_realtime_process, set_core_affinity
|
||||
from openpilot.system.hardware import HARDWARE, TICI
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.system.ui.mici_setup import (NetworkSetupPage, FailedPage, NetworkConnectivityMonitor,
|
||||
GreyBigButton, BigPillButton)
|
||||
|
||||
|
||||
class UpdaterNetworkSetupPage(NetworkSetupPage):
|
||||
def __init__(self, network_monitor, continue_callback):
|
||||
super().__init__(network_monitor, continue_callback, back_callback=None)
|
||||
self._continue_button.set_text("download\n& install")
|
||||
self._continue_button.set_green(False)
|
||||
|
||||
|
||||
class ProgressPage(NavWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._progress_title_label = UnifiedLabel("", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
||||
font_weight=FontWeight.DISPLAY, line_height=0.8)
|
||||
self._progress_percent_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
|
||||
font_weight=FontWeight.ROMAN,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
def set_progress(self, text: str, value: int):
|
||||
self._progress_title_label.set_text(text.replace("_", "_\n") + "...")
|
||||
self._progress_percent_label.set_text(f"{value}%")
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._nav_bar._alpha = 0.0 # not dismissable
|
||||
self.set_progress("downloading", 0)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
rl.draw_rectangle_rec(rect, rl.BLACK)
|
||||
self._progress_title_label.render(rl.Rectangle(
|
||||
rect.x + 12,
|
||||
rect.y + 2,
|
||||
rect.width,
|
||||
self._progress_title_label.get_content_height(int(rect.width - 20)),
|
||||
))
|
||||
|
||||
self._progress_percent_label.render(rl.Rectangle(
|
||||
rect.x + 12,
|
||||
rect.y + 18,
|
||||
rect.width,
|
||||
rect.height,
|
||||
))
|
||||
|
||||
|
||||
class Updater(Scroller):
|
||||
def __init__(self, updater_path, manifest_path):
|
||||
super().__init__()
|
||||
self.updater = updater_path
|
||||
self.manifest = manifest_path
|
||||
|
||||
self.progress_value = 0
|
||||
self.progress_text = "loading"
|
||||
self.process = None
|
||||
self.update_thread = None
|
||||
self._update_failed = False
|
||||
|
||||
self._network_monitor = NetworkConnectivityMonitor()
|
||||
self._network_monitor.start()
|
||||
|
||||
self._network_setup_page = UpdaterNetworkSetupPage(self._network_monitor, self._network_setup_continue_callback)
|
||||
|
||||
self._progress_page = ProgressPage()
|
||||
|
||||
self._failed_page = FailedPage(self._retry, title="update failed")
|
||||
|
||||
self._continue_button = BigPillButton("next")
|
||||
self._continue_button.set_click_callback(lambda: gui_app.push_widget(self._network_setup_page))
|
||||
|
||||
self._scroller.add_widgets([
|
||||
GreyBigButton("update required", "the download size\nis approximately 1 GB",
|
||||
gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 64, 64)),
|
||||
self._continue_button,
|
||||
])
|
||||
|
||||
gui_app.add_nav_stack_tick(self._nav_stack_tick)
|
||||
|
||||
def _network_setup_continue_callback(self, _):
|
||||
self.install_update()
|
||||
|
||||
def _retry(self):
|
||||
gui_app.pop_widgets_to(self)
|
||||
|
||||
def _nav_stack_tick(self):
|
||||
self._progress_page.set_progress(self.progress_text, self.progress_value)
|
||||
|
||||
if self._update_failed:
|
||||
self._update_failed = False
|
||||
self.show_event()
|
||||
gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._failed_page))
|
||||
|
||||
def install_update(self):
|
||||
self.progress_value = 0
|
||||
self.progress_text = "downloading"
|
||||
|
||||
def start_update():
|
||||
self.update_thread = threading.Thread(target=self._run_update_process, daemon=True)
|
||||
self.update_thread.start()
|
||||
|
||||
# Start the update process in a separate thread *after* show animation completes
|
||||
self._progress_page.set_shown_callback(start_update)
|
||||
gui_app.push_widget(self._progress_page)
|
||||
|
||||
def _run_update_process(self):
|
||||
# TODO: just import it and run in a thread without a subprocess
|
||||
try:
|
||||
cmd = [self.updater, "--swap", self.manifest]
|
||||
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
text=True, bufsize=1, universal_newlines=True)
|
||||
except Exception:
|
||||
self._update_failed = True
|
||||
return
|
||||
|
||||
if self.process.stdout is not None:
|
||||
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._update_failed = True
|
||||
|
||||
def close(self):
|
||||
self._network_monitor.stop()
|
||||
|
||||
|
||||
def main():
|
||||
config_realtime_process(0, 51)
|
||||
# attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off
|
||||
if TICI:
|
||||
try:
|
||||
set_core_affinity([5])
|
||||
except OSError:
|
||||
cloudlog.exception("Failed to set core affinity for updater process")
|
||||
|
||||
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)
|
||||
gui_app.push_widget(updater)
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
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("images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
|
||||
self._rotation = 0.0
|
||||
self._progress: int | None = None
|
||||
self._wrapped_lines: list[str] = []
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
if text.isdigit():
|
||||
self._progress = clamp(int(text), 0, 100)
|
||||
self._wrapped_lines = []
|
||||
else:
|
||||
self._progress = None
|
||||
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if self._wrapped_lines:
|
||||
# Calculate total height required for spinner and text
|
||||
spacing = WRAPPED_SPACING
|
||||
total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT
|
||||
center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
|
||||
else:
|
||||
# Center spinner vertically
|
||||
spacing = CENTERED_SPACING
|
||||
center_y = rect.height / 2.0
|
||||
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
|
||||
|
||||
center = rl.Vector2(rect.width / 2.0, center_y)
|
||||
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
|
||||
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
|
||||
|
||||
delta_time = rl.get_frame_time()
|
||||
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
|
||||
|
||||
# Draw rotating spinner and static comma logo
|
||||
rl.draw_texture_pro(self._spinner_texture, rl.Rectangle(0, 0, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
rl.Rectangle(center.x, center.y, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
spinner_origin, self._rotation, rl.WHITE)
|
||||
rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE)
|
||||
|
||||
# Display the progress bar or text based on user input
|
||||
if self._progress is not None:
|
||||
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
|
||||
|
||||
bar.width *= self._progress / 100.0
|
||||
rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
|
||||
elif self._wrapped_lines:
|
||||
for i, line in enumerate(self._wrapped_lines):
|
||||
text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE)
|
||||
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
|
||||
FONT_SIZE, 0.0, rl.WHITE)
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
"""Non-blocking read of available lines from stdin."""
|
||||
lines = []
|
||||
while True:
|
||||
rlist, _, _ = select.select([sys.stdin], [], [], 0.0)
|
||||
if not rlist:
|
||||
break
|
||||
line = sys.stdin.readline().strip()
|
||||
if line == "":
|
||||
break
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
gui_app.init_window("Spinner")
|
||||
spinner = Spinner()
|
||||
for _ in gui_app.render():
|
||||
text_list = _read_stdin()
|
||||
if text_list:
|
||||
spinner.set_text(text_list[-1])
|
||||
|
||||
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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
+126
@@ -0,0 +1,126 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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", gui_app.request_close)
|
||||
self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
|
||||
self._reboot_button = Button("Reboot", lambda: 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, _):
|
||||
content_rect = rl.Rectangle(45, 200, self._rect.width - 90, self._rect.height - 245)
|
||||
|
||||
label_rect = rl.Rectangle(content_rect.x + 140, content_rect.y, content_rect.width - 280, 100 * FONT_SCALE)
|
||||
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
|
||||
|
||||
text_rect = rl.Rectangle(content_rect.x + 140, content_rect.y + 140, content_rect.width - 280, content_rect.height - 90 - 100 * FONT_SCALE)
|
||||
gui_text_box(text_rect, self._get_body_text(), 90)
|
||||
|
||||
button_height = 160
|
||||
button_spacing = 50
|
||||
button_top = content_rect.y + content_rect.height - button_height
|
||||
button_width = (content_rect.width - button_spacing) / 2.0
|
||||
|
||||
if self._reset_state != ResetState.RESETTING:
|
||||
if self._mode == ResetMode.RECOVER:
|
||||
self._reboot_button.render(rl.Rectangle(content_rect.x, button_top, button_width, button_height))
|
||||
elif self._mode == ResetMode.USER_RESET:
|
||||
self._cancel_button.render(rl.Rectangle(content_rect.x, button_top, button_width, button_height))
|
||||
|
||||
if self._reset_state != ResetState.FAILED:
|
||||
self._confirm_button.render(rl.Rectangle(content_rect.x + button_width + 50, button_top, button_width, button_height))
|
||||
else:
|
||||
self._reboot_button.render(rl.Rectangle(content_rect.x, button_top, content_rect.width, button_height))
|
||||
|
||||
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
|
||||
|
||||
gui_app.init_window("System Reset", 20)
|
||||
reset = Reset(mode)
|
||||
|
||||
gui_app.push_widget(reset)
|
||||
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+424
@@ -0,0 +1,424 @@
|
||||
#!/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 pyray as rl
|
||||
|
||||
from cereal import log
|
||||
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 DialogResult, 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()}"
|
||||
|
||||
INSTALLER_DESTINATION_PATH = "/tmp/installer"
|
||||
INSTALLER_URL_PATH = "/tmp/installer_url"
|
||||
|
||||
|
||||
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.state = SetupState.NETWORK_SETUP
|
||||
self.stop_network_check_thread.clear()
|
||||
self.start_network_check()
|
||||
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_ex(self.warning, rl.Vector2(rect.x + 150, rect.y + 110), 0.0, 1.0, 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.0)
|
||||
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.0)
|
||||
|
||||
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 == DialogResult.CONFIRM:
|
||||
url = self.keyboard.text
|
||||
self.keyboard.clear()
|
||||
if url:
|
||||
self.download(url)
|
||||
|
||||
# Cancel pressed
|
||||
elif result == DialogResult.CANCEL:
|
||||
self.state = SetupState.SOFTWARE_SELECTION
|
||||
|
||||
self.keyboard.reset(min_text_size=1)
|
||||
self.keyboard.set_title("Enter URL", "for Custom Software")
|
||||
self.keyboard.set_callback(handle_keyboard_result)
|
||||
gui_app.push_widget(self.keyboard)
|
||||
|
||||
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()
|
||||
gui_app.push_widget(setup)
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
setup.close()
|
||||
except Exception as e:
|
||||
print(f"Setup error: {e}")
|
||||
finally:
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/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
|
||||
try:
|
||||
cmd = [self.updater, "--swap", self.manifest]
|
||||
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
text=True, bufsize=1, universal_newlines=True)
|
||||
except Exception:
|
||||
self.progress_text = "Update failed"
|
||||
self.show_reboot_button = True
|
||||
return
|
||||
|
||||
if self.process.stdout is not None:
|
||||
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")
|
||||
gui_app.push_widget(Updater(updater_path, manifest_path))
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
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,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from typing import TypeVar
|
||||
from collections.abc import Callable
|
||||
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()
|
||||
|
||||
W = TypeVar('W', bound='Widget')
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
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._children: list[Widget] = []
|
||||
|
||||
self._enabled: bool | Callable[[], bool] = True
|
||||
self._is_visible: bool | Callable[[], bool] = True
|
||||
|
||||
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._touch_valid_callback: Callable[[], bool] | None = None
|
||||
self._click_delay: float | None = None # seconds to hold is_pressed after release
|
||||
self._click_release_time: float | 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:
|
||||
# if actually pressed or holding after release
|
||||
return any(self.__is_pressed) or self._click_release_time is not None
|
||||
|
||||
@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 = None) -> bool | int | None:
|
||||
if rect is not None:
|
||||
self.set_rect(rect)
|
||||
|
||||
self._update_state()
|
||||
|
||||
if self._click_release_time is not None and rl.get_time() >= self._click_release_time:
|
||||
self._click_release_time = None
|
||||
|
||||
if not self.is_visible:
|
||||
return None
|
||||
|
||||
self._layout()
|
||||
ret = self._render(self._rect)
|
||||
|
||||
if gui_app.show_touches:
|
||||
self._draw_debug_rect()
|
||||
|
||||
# Keep track of whether mouse down started within the widget's rectangle
|
||||
if self.enabled and self.__was_awake:
|
||||
self._process_mouse_events()
|
||||
else:
|
||||
# TODO: ideally we emit release events when going disabled
|
||||
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
|
||||
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
|
||||
|
||||
self.__was_awake = device.awake
|
||||
|
||||
return ret
|
||||
|
||||
def _draw_debug_rect(self) -> None:
|
||||
rl.draw_rectangle_lines(int(self._rect.x), int(self._rect.y),
|
||||
max(int(self._rect.width), 1), max(int(self._rect.height), 1), rl.RED)
|
||||
|
||||
def _process_mouse_events(self) -> None:
|
||||
hit_rect = self._hit_rect
|
||||
touch_valid = self._touch_valid()
|
||||
|
||||
for mouse_event in gui_app.mouse_events:
|
||||
if not self._multi_touch and mouse_event.slot != 0:
|
||||
continue
|
||||
|
||||
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, hit_rect)
|
||||
# Ignores touches/presses that start outside our rect
|
||||
# Allows touch to leave the rect and come back in focus if mouse did not release
|
||||
if mouse_event.left_pressed and touch_valid:
|
||||
if mouse_in_rect:
|
||||
self._handle_mouse_press(mouse_event.pos)
|
||||
self.__is_pressed[mouse_event.slot] = True
|
||||
self.__tracking_is_pressed[mouse_event.slot] = True
|
||||
self._handle_mouse_event(mouse_event)
|
||||
|
||||
# Callback such as scroll panel signifies user is scrolling
|
||||
elif not touch_valid:
|
||||
self.__is_pressed[mouse_event.slot] = False
|
||||
self.__tracking_is_pressed[mouse_event.slot] = False
|
||||
|
||||
elif mouse_event.left_released:
|
||||
self._handle_mouse_event(mouse_event)
|
||||
if self.__is_pressed[mouse_event.slot] and mouse_in_rect:
|
||||
self._handle_mouse_release(mouse_event.pos)
|
||||
self.__is_pressed[mouse_event.slot] = False
|
||||
self.__tracking_is_pressed[mouse_event.slot] = False
|
||||
|
||||
# Mouse/touch is still within our rect
|
||||
elif mouse_in_rect:
|
||||
if self.__tracking_is_pressed[mouse_event.slot]:
|
||||
self.__is_pressed[mouse_event.slot] = True
|
||||
self._handle_mouse_event(mouse_event)
|
||||
|
||||
# Mouse/touch left our rect but may come back into focus later
|
||||
elif not mouse_in_rect:
|
||||
self.__is_pressed[mouse_event.slot] = False
|
||||
self._handle_mouse_event(mouse_event)
|
||||
|
||||
def _layout(self) -> None:
|
||||
"""Optionally lay out child widgets separately. This is called before rendering."""
|
||||
|
||||
def _update_state(self):
|
||||
"""Optionally update the widget's non-layout state. This is called before rendering."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _render(self, rect: rl.Rectangle) -> bool | int | None:
|
||||
"""Render the widget within the given rectangle."""
|
||||
|
||||
def _update_layout_rects(self) -> None:
|
||||
"""Optionally update any layout rects on Widget rect change."""
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos) -> None:
|
||||
"""Optionally handle mouse press events."""
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
|
||||
"""Optionally handle mouse release events."""
|
||||
if self._click_delay is not None:
|
||||
self._click_release_time = rl.get_time() + self._click_delay
|
||||
if self._click_callback:
|
||||
self._click_callback()
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
"""Optionally handle mouse events. This is called before rendering."""
|
||||
# Default implementation does nothing, can be overridden by subclasses
|
||||
|
||||
def _child(self, widget: W) -> W:
|
||||
"""
|
||||
Register a widget as a child. Lifecycle events (show/hide) propagate to registered children.
|
||||
- If the widget is pushed onto the nav stack, do NOT register it (gui_app manages its lifecycle).
|
||||
- If the widget is rendered inline in _render(), register it.
|
||||
"""
|
||||
assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}"
|
||||
self._children.append(widget)
|
||||
return widget
|
||||
|
||||
_show_hide_depth = 0
|
||||
|
||||
def show_event(self):
|
||||
"""Called when widget becomes visible. Propagates to registered children."""
|
||||
if DEBUG:
|
||||
print(f"{' ' * Widget._show_hide_depth}show_event: {type(self).__name__}")
|
||||
Widget._show_hide_depth += 1
|
||||
for child in self._children:
|
||||
child.show_event()
|
||||
if DEBUG:
|
||||
Widget._show_hide_depth -= 1
|
||||
|
||||
def hide_event(self):
|
||||
"""Called when widget is hidden. Propagates to registered children."""
|
||||
if DEBUG:
|
||||
print(f"{' ' * Widget._show_hide_depth}hide_event: {type(self).__name__}")
|
||||
Widget._show_hide_depth += 1
|
||||
for child in self._children:
|
||||
child.hide_event()
|
||||
if DEBUG:
|
||||
Widget._show_hide_depth -= 1
|
||||
|
||||
def dismiss(self, callback: Callable[[], None] | None = None):
|
||||
"""Immediately dismiss the widget, firing the callback after."""
|
||||
gui_app.pop_widget()
|
||||
if callback:
|
||||
callback()
|
||||
@@ -0,0 +1,225 @@
|
||||
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
|
||||
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_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, 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_ex(bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, 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_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white)
|
||||
@@ -0,0 +1,94 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
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, callback: Callable[[DialogResult], None] | None = None):
|
||||
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._callback = callback
|
||||
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 _cancel_button_callback(self):
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CANCEL)
|
||||
|
||||
def _confirm_button_callback(self):
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(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._confirm_button_callback()
|
||||
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
||||
self._cancel_button_callback()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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=gui_app.pop_widget, 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,16 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
|
||||
class IconWidget(Widget):
|
||||
def __init__(self, image_path: str, size: tuple[int, int], opacity: float = 1.0):
|
||||
super().__init__()
|
||||
self._texture = gui_app.texture(image_path, size[0], size[1])
|
||||
self._opacity = opacity
|
||||
self.set_rect(rl.Rectangle(0, 0, float(size[0]), float(size[1])))
|
||||
self.set_enabled(False)
|
||||
|
||||
def _render(self, _) -> None:
|
||||
color = rl.Color(255, 255, 255, int(self._opacity * 255))
|
||||
rl.draw_texture_ex(self._texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, color)
|
||||
@@ -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,282 @@
|
||||
from functools import partial
|
||||
import time
|
||||
from typing import Literal
|
||||
from collections.abc import Callable
|
||||
|
||||
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, 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,
|
||||
callback: Callable[[DialogResult], None] | None = None):
|
||||
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
|
||||
self._callback = callback
|
||||
|
||||
# Backspace key repeat tracking
|
||||
self._backspace_pressed: bool = False
|
||||
self._backspace_press_time: float = 0.0
|
||||
self._backspace_last_repeat: float = 0.0
|
||||
|
||||
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 set_callback(self, callback: Callable[[DialogResult], None] | None):
|
||||
self._callback = callback
|
||||
|
||||
def _eye_button_callback(self):
|
||||
self._password_mode = not self._password_mode
|
||||
|
||||
def _cancel_button_callback(self):
|
||||
self.clear()
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CANCEL)
|
||||
|
||||
def _key_callback(self, k):
|
||||
if k == ENTER_KEY:
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CONFIRM)
|
||||
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)
|
||||
|
||||
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._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__":
|
||||
def callback(result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
print(f"You typed: {keyboard.text}")
|
||||
elif result == DialogResult.CANCEL:
|
||||
print("Canceled")
|
||||
gui_app.request_close()
|
||||
|
||||
gui_app.init_window("Keyboard")
|
||||
keyboard = Keyboard(min_text_size=8, show_password_toggle=True, callback=callback)
|
||||
keyboard.set_title("Keyboard Input", "Type your text below")
|
||||
|
||||
gui_app.push_widget(keyboard)
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
gui_app.close()
|
||||
@@ -0,0 +1,722 @@
|
||||
import math
|
||||
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: 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, and Label.
|
||||
|
||||
Supports:
|
||||
- Emoji rendering
|
||||
- Text wrapping
|
||||
- Automatic eliding (single-line or multiline)
|
||||
- Proper multiline vertical alignment
|
||||
- Height calculation for layout purposes
|
||||
"""
|
||||
# Shimmer constants
|
||||
SHIMMER_BAND_WIDTH = 0.3 # shimmer width as fraction of text width
|
||||
SHIMMER_BLUR_RADIUS = 0.12 # gaussian blur as fraction of text width
|
||||
SHIMMER_CYCLE_PERIOD = 2.5 # seconds per full shimmer cycle
|
||||
SHIMMER_SWEEP_FRACTION = 0.9 # fraction of cycle spent sweeping (rest is pause)
|
||||
SHIMMER_LOW_OPACITY = 0.65 # text opacity at rest, shimmer brings to 1.0
|
||||
|
||||
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,
|
||||
shimmer: bool = False):
|
||||
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
|
||||
|
||||
# Shimmer state
|
||||
self._shimmer = shimmer
|
||||
self._shimmer_start_time = 0.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
|
||||
|
||||
# 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))
|
||||
|
||||
@property
|
||||
def font_size(self) -> int:
|
||||
return self._font_size
|
||||
|
||||
@property
|
||||
def text_width(self) -> float:
|
||||
return max((s.x for s in self._cached_line_sizes), default=0.0)
|
||||
|
||||
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_line_height(self, line_height: float):
|
||||
"""Update line height (multiplier, e.g., 1.0 = default)."""
|
||||
new_line_height = line_height * 0.9
|
||||
if self._line_height != new_line_height:
|
||||
self._line_height = new_line_height
|
||||
self._cached_text = None # Invalidate cache (affects total height)
|
||||
|
||||
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 show_event(self):
|
||||
super().show_event()
|
||||
if self._shimmer:
|
||||
self.reset_shimmer()
|
||||
|
||||
def reset_shimmer(self, offset: float = 0.0):
|
||||
"""Reset shimmer animation timing."""
|
||||
self._shimmer_start_time = rl.get_time() + offset
|
||||
|
||||
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)
|
||||
|
||||
# stop drawing left fade once text scrolls past
|
||||
text_width = visible_sizes[0].x if visible_sizes else 0
|
||||
first_copy_in_view = self._scroll_offset + text_width > 0
|
||||
draw_left_fade = self._scroll_state != ScrollState.STARTING and first_copy_in_view
|
||||
if draw_left_fade:
|
||||
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 _shimmer_alpha(self, char_x: float, shimmer_left: float, shimmer_width: float) -> float:
|
||||
"""Compute shimmer opacity multiplier for a character at the given x position."""
|
||||
sigma = shimmer_width * self.SHIMMER_BLUR_RADIUS
|
||||
if sigma <= 0:
|
||||
return self.SHIMMER_LOW_OPACITY
|
||||
|
||||
elapsed = rl.get_time() - self._shimmer_start_time
|
||||
t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD
|
||||
t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0))
|
||||
t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) # smoothstep
|
||||
|
||||
margin = shimmer_width * self.SHIMMER_BAND_WIDTH
|
||||
center = shimmer_left + shimmer_width + margin - t * (shimmer_width + 2.0 * margin)
|
||||
|
||||
d = char_x - center
|
||||
shimmer = math.exp(-0.5 * d * d / (sigma * sigma))
|
||||
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
|
||||
|
||||
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
|
||||
|
||||
if self._shimmer:
|
||||
self._render_line_shimmer(line, line_x, current_y)
|
||||
else:
|
||||
# Render line with emojis
|
||||
self._render_line_normal(line, emojis, line_x, current_y)
|
||||
|
||||
def _render_line_normal(self, line, emojis, line_x, current_y):
|
||||
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)
|
||||
|
||||
def _render_line_shimmer(self, line, line_x, current_y):
|
||||
# Shimmer range based on widest line so sweep is even across all lines
|
||||
max_width = self.text_width
|
||||
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
||||
shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width
|
||||
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
||||
shimmer_left = self._rect.x + (self._rect.width - max_width) / 2
|
||||
else:
|
||||
shimmer_left = self._rect.x + self._text_padding
|
||||
|
||||
base_a = self._text_color.a / 255.0
|
||||
cursor_x = line_x
|
||||
for ch in line:
|
||||
char_width = measure_text_cached(self._font, ch, self._font_size, self._spacing_pixels).x
|
||||
char_center_x = cursor_x + char_width / 2.0
|
||||
alpha = int(255 * self._shimmer_alpha(char_center_x, shimmer_left, max_width) * base_a)
|
||||
color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, alpha)
|
||||
rl.draw_text_ex(self._font, ch, rl.Vector2(cursor_x, current_y), self._font_size, 0, color)
|
||||
cursor_x += char_width + self._spacing_pixels
|
||||
@@ -0,0 +1,59 @@
|
||||
from enum import IntFlag
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
|
||||
class Alignment(IntFlag):
|
||||
LEFT = 0
|
||||
# TODO: implement
|
||||
# H_CENTER = 2
|
||||
# RIGHT = 4
|
||||
|
||||
TOP = 8
|
||||
V_CENTER = 16
|
||||
BOTTOM = 32
|
||||
|
||||
|
||||
class HBoxLayout(Widget):
|
||||
"""
|
||||
A Widget that lays out child Widgets horizontally.
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: list[Widget] | None = None, spacing: int = 0,
|
||||
alignment: Alignment = Alignment.LEFT | Alignment.V_CENTER):
|
||||
super().__init__()
|
||||
self._spacing = spacing
|
||||
self._alignment = alignment
|
||||
|
||||
if widgets is not None:
|
||||
for widget in widgets:
|
||||
self.add_widget(widget)
|
||||
|
||||
@property
|
||||
def widgets(self) -> list[Widget]:
|
||||
return self._children
|
||||
|
||||
def add_widget(self, widget: Widget) -> None:
|
||||
self._child(widget)
|
||||
|
||||
def _render(self, _):
|
||||
visible_widgets = [w for w in self._children if w.is_visible]
|
||||
|
||||
cur_offset_x = 0
|
||||
|
||||
for idx, widget in enumerate(visible_widgets):
|
||||
spacing = self._spacing if (idx > 0) else 0
|
||||
|
||||
x = self._rect.x + cur_offset_x + spacing
|
||||
cur_offset_x += widget.rect.width + spacing
|
||||
|
||||
if self._alignment & Alignment.TOP:
|
||||
y = self._rect.y
|
||||
elif self._alignment & Alignment.BOTTOM:
|
||||
y = self._rect.y + self._rect.height - widget.rect.height
|
||||
else: # center
|
||||
y = self._rect.y + (self._rect.height - widget.rect.height) / 2
|
||||
|
||||
# Update widget position and render
|
||||
widget.set_position(x, y)
|
||||
widget.set_parent_rect(self._rect)
|
||||
widget.render()
|
||||
@@ -0,0 +1,468 @@
|
||||
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 = None,
|
||||
right_callback: Callable | None = 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 = 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):
|
||||
super().show_event()
|
||||
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_ex(self._icon_texture, rl.Vector2(content_x, self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) / 2), 0.0, 1.0, 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 = None, right_callback: Callable | None = 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 = 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,405 @@
|
||||
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, font_weight: FontWeight = FontWeight.SEMI_BOLD):
|
||||
super().__init__()
|
||||
self.char = char
|
||||
self._font = gui_app.font(font_weight)
|
||||
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):
|
||||
# Smooth keys within parent rect
|
||||
base_y = self._parent_rect.y if self._parent_rect else 0.0
|
||||
local_y = y - base_y
|
||||
|
||||
if not self._position_initialized:
|
||||
self._x_filter.x = x
|
||||
self._y_filter.x = local_y
|
||||
# keep track of original position so dragging around feels consistent. also move touch area down a bit
|
||||
self.original_position = rl.Vector2(x, local_y + KEY_TOUCH_AREA_OFFSET)
|
||||
self._position_initialized = True
|
||||
|
||||
if not smooth:
|
||||
self._x_filter.x = x
|
||||
self._y_filter.x = local_y
|
||||
|
||||
self._rect.x = self._x_filter.update(x)
|
||||
self._rect.y = base_y + self._y_filter.update(local_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 round(self._size_filter.x)
|
||||
|
||||
|
||||
class SmallKey(Key):
|
||||
def __init__(self, chars: str):
|
||||
super().__init__(chars, FontWeight.BOLD)
|
||||
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 = "", icon_size: tuple[int, int] = (38, 38)):
|
||||
super().__init__(char)
|
||||
self._icon_size = icon_size
|
||||
self._icon = gui_app.texture(icon, *icon_size)
|
||||
self._vertical_align = vertical_align
|
||||
|
||||
def set_icon(self, icon: str, icon_size: tuple[int, int] | None = None):
|
||||
size = icon_size if icon_size is not None else self._icon_size
|
||||
self._icon = gui_app.texture(icon, *size)
|
||||
|
||||
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, auto_return_to_letters: str = ""):
|
||||
super().__init__()
|
||||
self._auto_return_to_letters = auto_return_to_letters
|
||||
|
||||
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", icon_size=(43, 14))
|
||||
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png", icon_size=(38, 33))
|
||||
# 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):
|
||||
# reset parent rect for new keys
|
||||
key.set_parent_rect(self._rect)
|
||||
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
|
||||
# use local y coords so parent widget offset (e.g. during NavWidget animate-in) doesn't affect hit testing
|
||||
dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - (mouse_pos.y - self._rect.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", icon_size=(38, 33))
|
||||
else:
|
||||
if self._caps_state == CapsState.LOWER:
|
||||
self._caps_state = CapsState.UPPER
|
||||
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png", icon_size=(38, 33))
|
||||
elif self._caps_state == CapsState.UPPER:
|
||||
self._caps_state = CapsState.LOCK
|
||||
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png", icon_size=(39, 38))
|
||||
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)
|
||||
|
||||
# Switch back to letters after common URL delimiters
|
||||
if self._closest_key[0].char in self._auto_return_to_letters and self._current_keys in (self._special_keys, self._super_special_keys):
|
||||
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) or not self.enabled:
|
||||
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_parent_rect(self._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,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent
|
||||
|
||||
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 = NAV_BAR_MARGIN + NAV_BAR_HEIGHT + 50 # px extra to push down when dismissing
|
||||
DISMISS_ANIMATION_RC = 0.2 # slightly slower for non-user triggered dismiss animation
|
||||
|
||||
|
||||
class NavBar(Widget):
|
||||
FADE_AFTER_SECONDS = 2.0
|
||||
|
||||
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 > self.FADE_AFTER_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__()
|
||||
# State
|
||||
self._drag_start_pos: MousePos | None = None # cleared after certain amount of horizontal movement
|
||||
self._dragging_down = False # swiped down enough to trigger dismissing on release
|
||||
self._playing_dismiss_animation = False # released and animating away
|
||||
self._y_pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
|
||||
|
||||
self._back_callback: Callable[[], None] | None = None # persistent callback for user-initiated back navigation
|
||||
self._dismiss_callback: Callable[[], None] | None = None # transient callback for programmatic dismiss
|
||||
# TODO: add this functionality to push_widget
|
||||
self._shown_callback: Callable[[], None] | None = None # transient callback fired after show animation completes
|
||||
|
||||
# TODO: move this state into NavBar
|
||||
self._nav_bar = self._child(NavBar())
|
||||
self._nav_bar_show_time = 0.0
|
||||
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
# Children can override this to block swipe away, like when not at
|
||||
# the top of a vertical scroll panel to prevent erroneous swipes
|
||||
return True
|
||||
|
||||
def set_back_callback(self, callback: Callable[[], None]) -> None:
|
||||
self._back_callback = callback
|
||||
|
||||
def set_shown_callback(self, callback: Callable[[], None] | None) -> None:
|
||||
self._shown_callback = callback
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
# Don't let touch events change filter state during dismiss animation
|
||||
if self._playing_dismiss_animation:
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
# user is able to swipe away if starting near top of screen
|
||||
self._y_pos_filter.update_alpha(0.04)
|
||||
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
|
||||
|
||||
if in_dismiss_area and self._back_enabled():
|
||||
self._drag_start_pos = mouse_event.pos
|
||||
|
||||
elif mouse_event.left_down:
|
||||
if self._drag_start_pos is not None:
|
||||
# block swiping away if too much horizontal or upward movement
|
||||
# block (lock-in) threshold is higher than start dismissing
|
||||
horizontal_movement = abs(mouse_event.pos.x - self._drag_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
|
||||
upward_movement = mouse_event.pos.y - self._drag_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
|
||||
|
||||
if not (horizontal_movement or upward_movement):
|
||||
# no blocking movement, check if we should start dismissing
|
||||
if mouse_event.pos.y - self._drag_start_pos.y > START_DISMISSING_THRESHOLD:
|
||||
self._dragging_down = True
|
||||
else:
|
||||
if not self._dragging_down:
|
||||
self._drag_start_pos = None
|
||||
|
||||
elif mouse_event.left_released:
|
||||
# reset rc for either slide up or down animation
|
||||
self._y_pos_filter.update_alpha(0.1)
|
||||
|
||||
# if far enough, trigger back navigation callback
|
||||
if self._drag_start_pos is not None:
|
||||
if mouse_event.pos.y - self._drag_start_pos.y > SWIPE_AWAY_THRESHOLD:
|
||||
self._playing_dismiss_animation = True
|
||||
|
||||
self._drag_start_pos = None
|
||||
self._dragging_down = False
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
new_y = 0.0
|
||||
|
||||
if self._dragging_down:
|
||||
self._nav_bar.set_alpha(1.0)
|
||||
|
||||
# FIXME: disabling this widget on new push_widget still causes this widget to track mouse events without mouse down
|
||||
if not self.enabled:
|
||||
self._drag_start_pos = None
|
||||
|
||||
if self._drag_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._drag_start_pos.y, 0)
|
||||
if new_y < SWIPE_AWAY_THRESHOLD:
|
||||
new_y /= 2 # resistance until mouse release would dismiss widget
|
||||
|
||||
if self._playing_dismiss_animation:
|
||||
new_y = self._rect.height + DISMISS_PUSH_OFFSET
|
||||
|
||||
new_y = self._y_pos_filter.update(new_y)
|
||||
if abs(new_y) < 1 and abs(self._y_pos_filter.velocity.x) < 0.5:
|
||||
new_y = self._y_pos_filter.x = 0.0
|
||||
self._y_pos_filter.velocity.x = 0.0
|
||||
|
||||
if self._shown_callback is not None:
|
||||
self._shown_callback()
|
||||
self._shown_callback = None
|
||||
|
||||
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
|
||||
gui_app.pop_widget()
|
||||
|
||||
# Only one callback should ever be fired
|
||||
if self._dismiss_callback is not None:
|
||||
self._dismiss_callback()
|
||||
self._dismiss_callback = None
|
||||
elif self._back_callback is not None:
|
||||
self._back_callback()
|
||||
|
||||
self._playing_dismiss_animation = False
|
||||
self._drag_start_pos = None
|
||||
self._dragging_down = False
|
||||
|
||||
self.set_position(self._rect.x, new_y)
|
||||
|
||||
def _layout(self):
|
||||
# Dim whatever is behind this widget, fading with position (runs after _update_state so position is correct)
|
||||
overlay_alpha = int(200 * max(0.0, min(1.0, 1.0 - self._rect.y / self._rect.height))) if self._rect.height > 0 else 0
|
||||
rl.draw_rectangle_rec(rl.Rectangle(0, 0, self._rect.width, self._rect.height), rl.Color(0, 0, 0, overlay_alpha))
|
||||
|
||||
bounce_height = 20
|
||||
rl.draw_rectangle_rec(rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height + bounce_height), rl.BLACK)
|
||||
|
||||
def render(self, rect: rl.Rectangle | None = None) -> bool | int | None:
|
||||
ret = super().render(rect)
|
||||
|
||||
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
|
||||
nav_bar_delayed = rl.get_time() - self._nav_bar_show_time < 0.4
|
||||
# User dragging or dismissing, nav bar follows NavWidget
|
||||
if self._drag_start_pos is not None or self._playing_dismiss_animation:
|
||||
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._y_pos_filter.x
|
||||
# Waiting to show
|
||||
elif nav_bar_delayed:
|
||||
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
|
||||
# Animate back to top
|
||||
else:
|
||||
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
|
||||
|
||||
self._nav_bar.set_position(bar_x, self._nav_bar_y_filter.x)
|
||||
self._nav_bar.render()
|
||||
|
||||
return ret
|
||||
|
||||
@property
|
||||
def is_dismissing(self) -> bool:
|
||||
return self._dragging_down or self._playing_dismiss_animation
|
||||
|
||||
def dismiss(self, callback: Callable[[], None] | None = None):
|
||||
"""Programmatically trigger the dismiss animation. Calls pop_widget when done, then callback."""
|
||||
if not self._playing_dismiss_animation:
|
||||
self._playing_dismiss_animation = True
|
||||
self._y_pos_filter.update_alpha(DISMISS_ANIMATION_RC)
|
||||
self._dismiss_callback = callback
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
|
||||
# Reset state
|
||||
self._drag_start_pos = None
|
||||
self._dragging_down = False
|
||||
self._playing_dismiss_animation = False
|
||||
self._dismiss_callback = None
|
||||
# Start NavWidget off-screen, no matter how tall it is
|
||||
self._y_pos_filter.update_alpha(0.1)
|
||||
self._y_pos_filter.x = gui_app.height
|
||||
self._y_pos_filter.velocity.x = 0.0
|
||||
|
||||
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
|
||||
self._nav_bar_show_time = rl.get_time()
|
||||
@@ -0,0 +1,493 @@
|
||||
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, normalize_ssid
|
||||
from openpilot.system.ui.widgets import DialogResult, Widget
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle, Button
|
||||
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
|
||||
|
||||
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
|
||||
except Exception:
|
||||
Params = None
|
||||
ui_state = None
|
||||
PrimeType = None
|
||||
|
||||
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 = self._child(WifiManagerUI(wifi_manager))
|
||||
self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager))
|
||||
self._nav_button = self._child(NavButton(tr("Advanced")))
|
||||
self._nav_button.set_click_callback(self._cycle_panel)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._set_current_panel(PanelType.WIFI)
|
||||
|
||||
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: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
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)
|
||||
self._keyboard.set_callback(update_apn)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
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: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
ssid = self._keyboard.text
|
||||
if not ssid:
|
||||
return
|
||||
|
||||
def enter_password(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
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))
|
||||
self._keyboard.set_callback(enter_password)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
self._keyboard.reset(min_text_size=1)
|
||||
self._keyboard.set_title(tr("Enter SSID"), "")
|
||||
self._keyboard.set_callback(connect_hidden)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _edit_tethering_password(self):
|
||||
def update_password(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
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)
|
||||
self._keyboard.set_callback(update_password)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
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):
|
||||
super().show_event()
|
||||
# start/stop scanning when widget is visible
|
||||
self._wifi_manager.set_active(True)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
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(normalize_ssid(self._state_network.ssid)))
|
||||
self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
|
||||
self.keyboard.set_callback(lambda result: self._on_password_entered(cast(Network, self._state_network), result))
|
||||
gui_app.push_widget(self.keyboard)
|
||||
elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network:
|
||||
confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"), callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
|
||||
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(normalize_ssid(self._state_network.ssid)))
|
||||
gui_app.push_widget(confirm_dialog)
|
||||
else:
|
||||
self._draw_network_list(rect)
|
||||
|
||||
def _on_password_entered(self, network: Network, result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
password = self.keyboard.text
|
||||
self.keyboard.clear()
|
||||
|
||||
if len(password) >= MIN_PASSWORD_LENGTH:
|
||||
self.connect_to_network(network, password)
|
||||
elif result == DialogResult.CANCEL:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def on_forgot_confirm_finished(self, network, result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
self.forget_network(network)
|
||||
elif result == DialogResult.CANCEL:
|
||||
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 self._wifi_manager.is_connection_saved(network.ssid):
|
||||
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 self._wifi_manager.is_connection_saved(network.ssid) and network.security_type != SecurityType.OPEN:
|
||||
self.state = UIState.NEEDS_AUTH
|
||||
self._state_network = network
|
||||
self._password_retry = False
|
||||
elif self._wifi_manager.wifi_state.ssid != network.ssid:
|
||||
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 self._wifi_manager.connected_ssid == network.ssid 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 self._wifi_manager.is_connection_saved(network.ssid) 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(normalize_ssid(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")
|
||||
gui_app.push_widget(WifiManagerUI(WifiManager()))
|
||||
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
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, 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, callback: Callable[[DialogResult], None] | None = None):
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self.options = options
|
||||
self.current = current
|
||||
self.selection = current
|
||||
self._callback = callback
|
||||
|
||||
# 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):
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(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)
|
||||
@@ -0,0 +1,461 @@
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
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
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
|
||||
ITEM_SPACING = 20
|
||||
LINE_COLOR = rl.GRAY
|
||||
LINE_PADDING = 40
|
||||
ANIMATION_SCALE = 0.6
|
||||
|
||||
MOVE_LIFT = 20
|
||||
MOVE_OVERLAY_ALPHA = 0.65
|
||||
SCROLL_RC = 0.15
|
||||
|
||||
EDGE_SHADOW_WIDTH = 20
|
||||
|
||||
MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
|
||||
DO_ZOOM = False
|
||||
DO_JELLO = False
|
||||
|
||||
|
||||
class ScrollIndicator(Widget):
|
||||
HORIZONTAL_MARGIN = 4
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/horizontal_scroll_indicator.png", 96, 48)
|
||||
self._scroll_offset: float = 0.0
|
||||
self._content_size: float = 0.0
|
||||
self._viewport: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
def update(self, scroll_offset: float, content_size: float, viewport: rl.Rectangle) -> None:
|
||||
self._scroll_offset = scroll_offset
|
||||
self._content_size = content_size
|
||||
self._viewport = viewport
|
||||
|
||||
def _render(self, _):
|
||||
# scale indicator width based on content size
|
||||
indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100]))
|
||||
|
||||
# position based on scroll ratio
|
||||
slide_range = self._viewport.width - indicator_w
|
||||
max_scroll = self._content_size - self._viewport.width
|
||||
scroll_ratio = (-self._scroll_offset / abs(max_scroll)) if abs(max_scroll) > 1e-3 else 0.0
|
||||
x = self._viewport.x + scroll_ratio * slide_range
|
||||
# don't bounce up when NavWidget shows
|
||||
y = max(self._viewport.y, 0) + self._viewport.height - self._txt_scroll_indicator.height / 2
|
||||
|
||||
# squeeze when overscrolling past edges
|
||||
dest_left = max(x, self._viewport.x)
|
||||
dest_right = min(x + indicator_w, self._viewport.x + self._viewport.width)
|
||||
dest_w = max(indicator_w / 2, dest_right - dest_left)
|
||||
|
||||
# keep within viewport after applying minimum width
|
||||
dest_left = min(dest_left, self._viewport.x + self._viewport.width - dest_w)
|
||||
dest_left = max(dest_left, self._viewport.x)
|
||||
|
||||
src_rec = rl.Rectangle(0, 0, self._txt_scroll_indicator.width, self._txt_scroll_indicator.height)
|
||||
dest_rec = rl.Rectangle(dest_left, y, dest_w, self._txt_scroll_indicator.height)
|
||||
rl.draw_texture_pro(self._txt_scroll_indicator, src_rec, dest_rec, rl.Vector2(0, 0), 0.0,
|
||||
rl.Color(255, 255, 255, int(255 * 0.45)))
|
||||
|
||||
|
||||
class _Scroller(Widget):
|
||||
"""Should use wrapper below to reduce boilerplate"""
|
||||
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING,
|
||||
pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True):
|
||||
super().__init__()
|
||||
self._items: list[Widget] = []
|
||||
self._horizontal = horizontal
|
||||
self._snap_items = snap_items
|
||||
self._spacing = spacing
|
||||
self._pad = pad
|
||||
|
||||
self._reset_scroll_at_show = True
|
||||
|
||||
self._scrolling_to: tuple[float | None, bool] = (None, False) # target offset, block_interaction
|
||||
self._scrolling_to_filter = FirstOrderFilter(0.0, SCROLL_RC, 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._show_scroll_indicator = scroll_indicator and self._horizontal
|
||||
self._scroll_indicator = ScrollIndicator()
|
||||
self._edge_shadows = edge_shadows and self._horizontal
|
||||
|
||||
# move animation state
|
||||
# on move; lift src widget -> wait -> move all -> wait -> drop src widget
|
||||
self._overlay_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
self._move_animations: dict[Widget, FirstOrderFilter] = {}
|
||||
self._move_lift: dict[Widget, FirstOrderFilter] = {}
|
||||
# these are used to wait before moving/dropping, also to move onto next part of the animation earlier for timing
|
||||
self._pending_lift: set[Widget] = set()
|
||||
self._pending_move: set[Widget] = set()
|
||||
|
||||
self.add_widgets(items)
|
||||
|
||||
def set_reset_scroll_at_show(self, scroll: bool):
|
||||
self._reset_scroll_at_show = scroll
|
||||
|
||||
def scroll_to(self, pos: float, smooth: bool = False, block_interaction: bool = False):
|
||||
assert not block_interaction or smooth, "Instant scroll cannot block user interaction"
|
||||
|
||||
# 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_filter.x = self.scroll_panel.get_offset()
|
||||
self._scrolling_to = scroll_offset, block_interaction
|
||||
else:
|
||||
self.scroll_panel.set_offset(scroll_offset)
|
||||
|
||||
@property
|
||||
def is_auto_scrolling(self) -> bool:
|
||||
return self._scrolling_to[0] is not None
|
||||
|
||||
@property
|
||||
def items(self) -> list[Widget]:
|
||||
return self._items
|
||||
|
||||
@property
|
||||
def content_size(self) -> float:
|
||||
return self._content_size
|
||||
|
||||
def add_widget(self, item: Widget) -> None:
|
||||
self._items.append(item)
|
||||
|
||||
# preserve original touch valid callback
|
||||
original_touch_valid_callback = item._touch_valid_callback
|
||||
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and self._scrolling_to[0] is None
|
||||
and not self.moving_items and (original_touch_valid_callback() if
|
||||
original_touch_valid_callback else True))
|
||||
|
||||
def add_widgets(self, items: list[Widget]) -> None:
|
||||
for item in items:
|
||||
self.add_widget(item)
|
||||
|
||||
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[0] 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 (unless block_interaction)
|
||||
if (self.scroll_panel.state in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL) and
|
||||
self._scrolling_to[0] is not None and not self._scrolling_to[1]):
|
||||
self._scrolling_to = None, False
|
||||
|
||||
if self._scrolling_to[0] is not None and len(self._pending_lift) == 0:
|
||||
self._scrolling_to_filter.update(self._scrolling_to[0])
|
||||
self.scroll_panel.set_offset(self._scrolling_to_filter.x)
|
||||
|
||||
if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1:
|
||||
self.scroll_panel.set_offset(self._scrolling_to[0])
|
||||
self._scrolling_to = None, False
|
||||
|
||||
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 and not self._scrolling_to[1])
|
||||
self.scroll_panel.update(self._rect, content_size)
|
||||
if not self._snap_items:
|
||||
return 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()
|
||||
|
||||
@property
|
||||
def moving_items(self) -> bool:
|
||||
return len(self._move_animations) > 0 or len(self._move_lift) > 0
|
||||
|
||||
def move_item(self, from_idx: int, to_idx: int):
|
||||
assert self._horizontal
|
||||
if from_idx == to_idx:
|
||||
return
|
||||
|
||||
if self.moving_items:
|
||||
cloudlog.warning(f"Already moving items, cannot move from {from_idx} to {to_idx}")
|
||||
return
|
||||
|
||||
item = self._items.pop(from_idx)
|
||||
self._items.insert(to_idx, item)
|
||||
|
||||
# store original position in content space of all affected widgets to animate from
|
||||
for idx in range(min(from_idx, to_idx), max(from_idx, to_idx) + 1):
|
||||
affected_item = self._items[idx]
|
||||
self._move_animations[affected_item] = FirstOrderFilter(affected_item.rect.x - self._scroll_offset, SCROLL_RC, 1 / gui_app.target_fps)
|
||||
self._pending_move.add(affected_item)
|
||||
|
||||
# lift only src widget to make it more clear which one is moving
|
||||
self._move_lift[item] = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps)
|
||||
self._pending_lift.add(item)
|
||||
|
||||
def _do_move_animation(self, item: Widget, target_x: float, target_y: float) -> tuple[float, float]:
|
||||
# wait a frame before moving so we match potential pending scroll animation
|
||||
can_start_move = len(self._pending_lift) == 0
|
||||
|
||||
if item in self._move_lift:
|
||||
lift_filter = self._move_lift[item]
|
||||
|
||||
# Animate lift
|
||||
if len(self._pending_move) > 0:
|
||||
lift_filter.update(MOVE_LIFT)
|
||||
# start moving when almost lifted
|
||||
if abs(lift_filter.x - MOVE_LIFT) < 2:
|
||||
self._pending_lift.discard(item)
|
||||
else:
|
||||
# if done moving, animate down
|
||||
lift_filter.update(0)
|
||||
if abs(lift_filter.x) < 1:
|
||||
del self._move_lift[item]
|
||||
target_y -= lift_filter.x
|
||||
|
||||
# Animate move
|
||||
if item in self._move_animations:
|
||||
move_filter = self._move_animations[item]
|
||||
|
||||
# compare/update in content space to match filter
|
||||
content_x = target_x - self._scroll_offset
|
||||
if can_start_move:
|
||||
move_filter.update(content_x)
|
||||
|
||||
# drop when close to target
|
||||
if abs(move_filter.x - content_x) < 10:
|
||||
self._pending_move.discard(item)
|
||||
|
||||
# finished moving
|
||||
if abs(move_filter.x - content_x) < 1:
|
||||
del self._move_animations[item]
|
||||
target_x = move_filter.x + self._scroll_offset
|
||||
|
||||
return target_x, target_y
|
||||
|
||||
def _layout(self):
|
||||
self._visible_items = [item for item in self._items if item.is_visible]
|
||||
|
||||
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 * 2
|
||||
|
||||
self._scroll_offset = self._get_scroll(self._visible_items, self._content_size)
|
||||
|
||||
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
|
||||
# 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)
|
||||
|
||||
# Animate moves if needed
|
||||
x, y = self._do_move_animation(item, x, y)
|
||||
|
||||
# Update item state
|
||||
item.set_position(x, y)
|
||||
item.set_parent_rect(self._rect)
|
||||
|
||||
def _render_item(self, item: Widget):
|
||||
# Skip rendering if not in viewport
|
||||
if not rl.check_collision_recs(item.rect, self._rect):
|
||||
return
|
||||
|
||||
# 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()
|
||||
|
||||
def _render(self, _):
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
|
||||
int(self._rect.width), int(self._rect.height))
|
||||
|
||||
for item in reversed(self._visible_items):
|
||||
if item in self._move_lift:
|
||||
continue
|
||||
self._render_item(item)
|
||||
|
||||
# Dim background if moving items, lifted items are above
|
||||
self._overlay_filter.update(MOVE_OVERLAY_ALPHA if len(self._pending_move) else 0.0)
|
||||
if self._overlay_filter.x > 0.01:
|
||||
rl.draw_rectangle_rec(self._rect, rl.Color(0, 0, 0, int(255 * self._overlay_filter.x)))
|
||||
|
||||
for item in self._move_lift:
|
||||
self._render_item(item)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# Draw edge shadows on top of scroller content
|
||||
if self._edge_shadows:
|
||||
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y),
|
||||
EDGE_SHADOW_WIDTH, int(self._rect.height),
|
||||
rl.Color(0, 0, 0, 204), rl.BLANK)
|
||||
|
||||
right_x = int(self._rect.x + self._rect.width - EDGE_SHADOW_WIDTH)
|
||||
rl.draw_rectangle_gradient_h(right_x, int(self._rect.y),
|
||||
EDGE_SHADOW_WIDTH, int(self._rect.height),
|
||||
rl.BLANK, rl.Color(0, 0, 0, 204))
|
||||
|
||||
# Draw scroll indicator on top of edge shadows
|
||||
if self._show_scroll_indicator and len(self._visible_items) > 0:
|
||||
self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect)
|
||||
self._scroll_indicator.render()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
for item in self._items:
|
||||
item.show_event()
|
||||
|
||||
if self._reset_scroll_at_show:
|
||||
self.scroll_panel.set_offset(0.0)
|
||||
|
||||
self._overlay_filter.x = 0.0
|
||||
self._move_animations.clear()
|
||||
self._move_lift.clear()
|
||||
self._pending_lift.clear()
|
||||
self._pending_move.clear()
|
||||
self._scrolling_to = None, False
|
||||
self._scrolling_to_filter.x = 0.0
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
for item in self._items:
|
||||
item.hide_event()
|
||||
|
||||
|
||||
class Scroller(Widget):
|
||||
"""Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack."""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__()
|
||||
self._scroller = self._child(_Scroller([], **kwargs))
|
||||
# pass down enabled to child widget for nav stack
|
||||
self._scroller.set_enabled(lambda: self.enabled)
|
||||
|
||||
def _render(self, _):
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
class NavScroller(NavWidget, Scroller):
|
||||
"""Full screen Scroller that properly supports nav stack w/ animations"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# pass down enabled to child widget for nav stack + disable while swiping away NavWidget
|
||||
self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
# Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes
|
||||
# TODO: only used for offroad alerts, remove when horizontal
|
||||
return self._scroller._horizontal or self._scroller.scroll_panel.get_offset() >= -20 # some tolerance
|
||||
|
||||
|
||||
# TODO: only used for a few vertical scrollers, remove when horizontal
|
||||
class NavRawScrollPanel(NavWidget):
|
||||
# can swipe anywhere, only when at top
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 1.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._scroll_panel = GuiScrollPanel2(horizontal=False)
|
||||
self._scroll_panel.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroll_panel.set_offset(0)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return self._scroll_panel.get_offset() >= -20
|
||||
@@ -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,204 @@
|
||||
import abc
|
||||
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, BounceFilter
|
||||
|
||||
|
||||
class SliderBase(Widget, abc.ABC):
|
||||
HORIZONTAL_PADDING = 8
|
||||
CONFIRM_DELAY = 0.2
|
||||
PRESSED_SCALE = 1.07
|
||||
|
||||
_bg_txt: rl.Texture
|
||||
_circle_bg_txt: rl.Texture
|
||||
_circle_bg_pressed_txt: rl.Texture
|
||||
_circle_arrow_txt: rl.Texture
|
||||
|
||||
def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._shimmer_offset = shimmer_offset
|
||||
|
||||
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._circle_scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._circle_press_time: float | None = None
|
||||
|
||||
self._is_dragging_circle = False
|
||||
|
||||
self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load_assets(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def confirmed(self) -> bool:
|
||||
return self._confirmed_time > 0.0
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
# reset all slider state
|
||||
self._is_dragging_circle = False
|
||||
self._circle_press_time = None
|
||||
self._confirmed_time = 0.0
|
||||
self._confirm_callback_called = False
|
||||
self._label.reset_shimmer(self._shimmer_offset)
|
||||
|
||||
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
|
||||
self._circle_press_time = rl.get_time()
|
||||
|
||||
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:
|
||||
# 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._confirm_callback_called = True
|
||||
self._on_confirm()
|
||||
|
||||
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, _):
|
||||
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
|
||||
|
||||
label_alpha = int(255 * (1.0 - self.slider_percentage) * self._opacity_filter.x)
|
||||
if label_alpha > 0:
|
||||
self._label.set_text_color(rl.Color(255, 255, 255, label_alpha))
|
||||
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 with grow animation
|
||||
circle_pressed = self._is_dragging_circle or self.confirmed or (self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075)
|
||||
circle_bg_txt = self._circle_bg_pressed_txt if circle_pressed else self._circle_bg_txt
|
||||
scale = self._circle_scale_filter.update(self.PRESSED_SCALE if circle_pressed else 1.0)
|
||||
scaled_btn_x = btn_x + (self._circle_bg_txt.width * (1 - scale)) / 2
|
||||
scaled_btn_y = btn_y + (self._circle_bg_txt.height * (1 - scale)) / 2
|
||||
rl.draw_texture_ex(circle_bg_txt, rl.Vector2(scaled_btn_x, scaled_btn_y), 0.0, scale, white)
|
||||
|
||||
arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
|
||||
arrow_y = scaled_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(SliderBase):
|
||||
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0):
|
||||
self._green = green
|
||||
super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset)
|
||||
|
||||
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_bg_pressed_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}_pressed.png", 180, 115)
|
||||
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
|
||||
|
||||
|
||||
class BigSlider(SliderBase):
|
||||
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.set_font_size(48)
|
||||
self._label.set_font_weight(FontWeight.DISPLAY)
|
||||
self._label.set_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_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.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_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.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