This commit is contained in:
firestar5683
2026-04-11 21:56:46 -05:00
parent d2e5f06395
commit d43b7d0d3f
187 changed files with 5499 additions and 6222 deletions
+205 -218
View File
@@ -1,6 +1,8 @@
import atexit
import cffi
import math
import os
import queue
import time
import signal
import sys
@@ -11,7 +13,6 @@ import subprocess
from contextlib import contextmanager
from collections.abc import Callable
from collections import deque
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import NamedTuple
@@ -40,6 +41,10 @@ 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
@@ -51,9 +56,7 @@ if platform.system() == "Darwin":
"""
BURN_IN_MODE = "BURN_IN" in os.environ
BURN_IN_VERTEX_SHADER = (
GL_VERSION
+ """
BURN_IN_VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
@@ -63,10 +66,7 @@ void main() {
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
)
BURN_IN_FRAGMENT_SHADER = (
GL_VERSION
+ """
BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
out vec4 fragColor;
@@ -82,7 +82,6 @@ void main() {
fragColor = vec4(gradient, sampled.a);
}
"""
)
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
@@ -96,13 +95,10 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
LIGHT = "Inter-Light.fnt"
NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
BOLD = "Inter-Bold.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
EXTRA_BOLD = "Inter-ExtraBold.fnt"
BLACK = "Inter-Black.fnt"
UNIFONT = "unifont.fnt"
# Small UI fonts
@@ -118,12 +114,6 @@ def font_fallback(font: rl.Font) -> rl.Font:
return font
@dataclass
class ModalOverlay:
overlay: object = None
callback: Callable | None = None
class MousePos(NamedTuple):
x: float
y: float
@@ -179,6 +169,10 @@ class MouseState:
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
@@ -192,7 +186,8 @@ class MouseState:
time.monotonic(),
)
# Only add changes
if self._prev_mouse_event[slot] is None or ev[:-1] != self._prev_mouse_event[slot][:-1]:
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
@@ -200,6 +195,8 @@ class MouseState:
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()
@@ -218,17 +215,17 @@ class GuiApplication:
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._trace_log_callback = None
self._progress_hook: Callable[[str], None] | None = None
self._modal_overlay = ModalOverlay()
self._modal_overlay_shown = False
self._modal_overlay_tick: Callable[[], None] | None = None
self._nav_stack: list = []
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] = []
@@ -255,6 +252,10 @@ class GuiApplication:
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
@@ -262,31 +263,14 @@ class GuiApplication:
def request_close(self):
self._window_close_requested = True
def set_progress_hook(self, hook: Callable[[str], None] | None):
self._progress_hook = hook
def _mark_progress(self, phase: str):
if self._progress_hook is None:
return
try:
self._progress_hook(phase)
except Exception:
pass
def init_window(self, title: str, fps: int = _DEFAULT_FPS):
with self._startup_profile_context():
def _close(sig, frame):
self.close()
sys.exit(0)
signal.signal(signal.SIGINT, _close)
atexit.register(self.close)
self._set_log_callback()
rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING)
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
if ENABLE_VSYNC:
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
@@ -298,44 +282,48 @@ class GuiApplication:
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
if needs_render_texture:
self._render_texture = rl.load_render_texture(self._width, self._height)
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
'-stats', # Show encoding progress
'-f',
'rawvideo', # Input format
'-pix_fmt',
'rgba', # Input pixel format
'-s',
f'{self._width}x{self._height}', # Input resolution
'-r',
str(fps), # Input frame rate
'-i',
'pipe:0', # Input from stdin
'-vf',
'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v',
'libx264', # Video codec
'-preset',
'ultrafast', # Encoding speed
'-y', # Overwrite existing file
'-f',
'mp4', # Output format
RECORD_OUTPUT, # Output file path
'-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()
rl.set_target_fps(fps)
# 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)
@@ -372,93 +360,132 @@ class GuiApplication:
print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}")
sys.exit(0)
def set_modal_overlay(self, overlay, callback: Callable | None = None):
if self._modal_overlay.overlay is not None:
if hasattr(self._modal_overlay.overlay, 'hide_event'):
self._modal_overlay.overlay.hide_event()
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
if self._modal_overlay.callback is not None:
self._modal_overlay.callback(-1)
self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback)
def set_modal_overlay_tick(self, tick_function: Callable | None):
self._modal_overlay_tick = tick_function
def push_widget(self, widget):
def push_widget(self, widget: object):
if widget in self._nav_stack:
cloudlog.warning("Widget already in stack, cannot push again!")
return
if self._nav_stack:
prev = self._nav_stack[-1]
if hasattr(prev, 'set_enabled'):
prev.set_enabled(False)
# 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)
if hasattr(widget, 'show_event'):
widget.show_event()
if hasattr(widget, 'set_enabled'):
widget.set_enabled(True)
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
if idx_to_pop == len(self._nav_stack) - 1:
prev = self._nav_stack[idx_to_pop - 1]
if hasattr(prev, 'set_enabled'):
prev.set_enabled(True)
widget = self._nav_stack.pop(idx_to_pop)
if hasattr(widget, 'hide_event'):
widget.hide_event()
def _render_nav_stack(self) -> bool:
if not self._nav_stack:
return False
widget = self._nav_stack[-1]
if hasattr(widget, 'render'):
widget.render(rl.Rectangle(0, 0, self.width, self.height))
return True
# 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):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
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)
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 starpilot_texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True):
"""Load a texture from the StarPilot assets folder."""
cache_key = f"starpilot_{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
starpilot_assets = files("openpilot.starpilot").joinpath("assets")
with as_file(starpilot_assets.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
def _load_image_from_path(
self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True
) -> rl.Image:
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 image.width == 0 or image.height == 0:
return image
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
@@ -481,6 +508,10 @@ class GuiApplication:
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:
@@ -495,11 +526,17 @@ class GuiApplication:
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=5)
self._ffmpeg_proc.wait(timeout=30)
except subprocess.TimeoutExpired:
self._ffmpeg_proc.terminate()
self._ffmpeg_proc.wait()
@@ -539,17 +576,15 @@ class GuiApplication:
def last_mouse_event(self) -> MouseEvent:
return self._last_mouse_event
def render(self, render_callback: Callable[[], None] | None = None):
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()):
self._mark_progress("gui_app.loop_start")
if PC:
# Thread is not used on PC, need to manually add mouse events
self._mouse._handle_mouse_event()
@@ -561,7 +596,6 @@ class GuiApplication:
# Skip rendering when screen is off
if not self._should_render:
self._mark_progress("gui_app.skip_render")
if PC:
rl.poll_input_events()
time.sleep(1 / self._target_fps)
@@ -569,59 +603,43 @@ class GuiApplication:
continue
if self._render_texture:
self._mark_progress("gui_app.before_begin_texture_mode")
rl.begin_texture_mode(self._render_texture)
self._mark_progress("gui_app.after_begin_texture_mode")
self._mark_progress("gui_app.before_clear_background")
rl.clear_background(rl.BLACK)
self._mark_progress("gui_app.after_clear_background")
else:
self._mark_progress("gui_app.before_begin_drawing")
rl.begin_drawing()
self._mark_progress("gui_app.after_begin_drawing")
self._mark_progress("gui_app.before_clear_background")
rl.clear_background(rl.BLACK)
self._mark_progress("gui_app.after_clear_background")
# Handle modal overlay rendering and input processing
if self._render_nav_stack():
self._mark_progress("gui_app.nav_stack")
yield False
elif self._handle_modal_overlay():
# Allow a Widget to still run a function while overlay is shown
if self._modal_overlay_tick is not None:
self._modal_overlay_tick()
self._mark_progress("gui_app.modal_overlay")
yield False
else:
self._mark_progress("gui_app.frame_ready")
if render_callback is not None:
self._mark_progress("gui_app.before_render_callback")
render_callback()
self._mark_progress("gui_app.after_render_callback")
yield True
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:
self._mark_progress("gui_app.end_texture_mode")
rl.end_texture_mode()
self._mark_progress("gui_app.before_present_begin_drawing")
rl.begin_drawing()
self._mark_progress("gui_app.after_present_begin_drawing")
self._mark_progress("gui_app.before_present_clear_background")
rl.clear_background(rl.BLACK)
self._mark_progress("gui_app.after_present_clear_background")
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
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:
self._mark_progress("gui_app.before_present_draw_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)
self._mark_progress("gui_app.after_present_draw_texture")
if self._show_fps:
rl.draw_fps(10, 10)
@@ -632,21 +650,17 @@ class GuiApplication:
if self._grid_size > 0:
self._draw_grid()
self._mark_progress("gui_app.end_drawing")
rl.end_drawing()
self._mark_progress("gui_app.after_end_drawing")
if RECORD:
image = rl.load_image_from_texture(self._render_texture.texture)
data_size = image.width * image.height * 4
data = bytes(rl.ffi.buffer(image.data, data_size))
self._ffmpeg_proc.stdin.write(data)
self._ffmpeg_proc.stdin.flush()
self._ffmpeg_queue.put(data) # Async write via background thread
rl.unload_image(image)
self._monitor_fps()
self._frame += 1
self._mark_progress("gui_app.frame_complete")
if self._profile_render_frames > 0 and self._frame >= self._profile_render_frames:
self._output_render_profile()
@@ -664,61 +678,17 @@ class GuiApplication:
def height(self):
return self._height
def _handle_modal_overlay(self) -> bool:
if self._modal_overlay.overlay:
if hasattr(self._modal_overlay.overlay, 'render'):
result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height))
elif callable(self._modal_overlay.overlay):
result = self._modal_overlay.overlay()
else:
raise Exception
# Send show event to Widget
if not self._modal_overlay_shown and hasattr(self._modal_overlay.overlay, 'show_event'):
self._modal_overlay.overlay.show_event()
self._modal_overlay_shown = True
if result >= 0:
# Clear the overlay and execute the callback
original_modal = self._modal_overlay
self._modal_overlay = ModalOverlay()
if hasattr(original_modal.overlay, 'hide_event'):
original_modal.overlay.hide_event()
if original_modal.callback is not None:
original_modal.callback(result)
return True
else:
self._modal_overlay_shown = False
return False
def _load_fonts(self):
self._ensure_font_atlases()
with as_file(FONT_DIR) as fspath:
for font_weight_file in FontWeight:
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())
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
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 _ensure_font_atlases(self):
with as_file(FONT_DIR) as fspath:
required_fonts = [fspath / fw.value for fw in FontWeight]
missing_fonts = [font_path.name for font_path in required_fonts if not font_path.exists()]
if not missing_fonts:
return
process_script = fspath / "process.py"
if not process_script.exists():
cloudlog.warning(f"Missing font atlases {missing_fonts}, but no generator found at {process_script}")
return
cloudlog.warning(f"Generating missing font atlases: {missing_fonts}")
try:
subprocess.run([sys.executable, process_script.as_posix()], check=True, cwd=fspath.as_posix())
except Exception:
cloudlog.exception("Failed to generate font atlases")
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)
@@ -737,6 +707,20 @@ class GuiApplication:
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("""
@@ -773,6 +757,9 @@ class GuiApplication:
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)
@@ -842,11 +829,11 @@ class GuiApplication:
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}")
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
# 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()
+6 -6
View File
@@ -1,12 +1,13 @@
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
_emoji_font: ImageFont.FreeTypeFont | None = None
_cache: dict[str, rl.Texture] = {}
EMOJI_REGEX = re.compile(
@@ -33,11 +34,10 @@ EMOJI_REGEX = re.compile(
flags=re.UNICODE
)
def _load_emoji_font() -> ImageFont.FreeTypeFont | None:
global _emoji_font
if _emoji_font is None:
_emoji_font = ImageFont.truetype(str(FONT_DIR.joinpath("NotoColorEmoji.ttf")), 109)
return _emoji_font
@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)]
+141 -16
View File
@@ -1,7 +1,7 @@
from importlib.resources import files
import os
import json
import gettext
import os
import re
from openpilot.common.basedir import BASEDIR
from openpilot.common.swaglog import cloudlog
@@ -16,7 +16,6 @@ TRANSLATIONS_DIR = UI_DIR.joinpath("translations")
LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json")
UNIFONT_LANGUAGES = [
"ar",
"th",
"zh-CHT",
"zh-CHS",
@@ -24,14 +23,137 @@ UNIFONT_LANGUAGES = [
"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 = {}
self.codes = {}
self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations()
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
@@ -44,27 +166,30 @@ class Multilang:
def setup(self):
try:
with TRANSLATIONS_DIR.joinpath(f'app_{self._language}.mo').open('rb') as fh:
translation = gettext.GNUTranslations(fh)
translation.install()
self._translation = translation
cloudlog.warning(f"Loaded translations for language: {self._language}")
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.")
gettext.install('app')
self._translation = gettext.NullTranslations()
self._translations = {}
self._plurals = {}
def change_language(self, language_code: str) -> None:
# Reinstall gettext with the selected language
self._params.put("LanguageSetting", language_code)
self._language = language_code
self.setup()
def tr(self, text: str) -> str:
return self._translation.gettext(text)
return self._translations.get(text, text) or text
def trn(self, singular: str, plural: str, n: int) -> str:
return self._translation.ngettext(singular, plural, n)
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:
+21 -3
View File
@@ -3,14 +3,34 @@ 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
STATE_CONFIG = 50
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
@@ -29,8 +49,6 @@ NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
NM_DEVICE_TYPE_WIFI = 2
NM_DEVICE_TYPE_MODEM = 8
NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags
NM_802_11_AP_FLAGS_NONE = 0x0
+32 -3
View File
@@ -20,6 +20,21 @@ 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
@@ -73,8 +88,14 @@ class GuiScrollPanel2:
def _update_state(self, bounds_size: float, content_size: float) -> None:
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
if self._state == ScrollState.AUTO_SCROLL:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
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:
@@ -145,7 +166,13 @@ class GuiScrollPanel2:
# 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
# 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
@@ -160,6 +187,8 @@ class GuiScrollPanel2:
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
@@ -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
File diff suppressed because it is too large Load Diff
Regular → Executable
+92 -111
View File
@@ -1,78 +1,109 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
import threading
from enum import IntEnum
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.slider import SmallSlider
from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
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
PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON"))
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
TAP_RESET = 2 # user initiated a factory reset by tapping the screen during boot
class ResetState(IntEnum):
NONE = 0
RESETTING = 1
FAILED = 2
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 Reset(Widget):
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_reset_state = None
self._reset_state = ResetState.NONE
self._previous_active_widget = None
self._reset_failed = False
self._timeout_st = time.monotonic()
self._cancel_button = SmallButton("cancel")
self._cancel_button.set_click_callback(self._cancel_callback)
self._resetting_page = ResettingPage()
self._reset_failed_page = ResetFailedPage()
self._reboot_button = FullRoundedButton("reboot")
self._reboot_button.set_click_callback(self._do_reboot)
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)
self._confirm_slider = SmallSlider("reset", self._confirm)
# show reboot button if in recover mode
self._cancel_button.set_visible(mode != ResetMode.RECOVER)
self._reboot_button.set_visible(mode == ResetMode.RECOVER)
self._render_status = True
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)
def _cancel_callback(self):
self._render_status = False
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")
def _do_reboot(self):
if PC:
return
self._scroller.add_widgets([
GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"),
self._cancel_button,
self._reboot_button,
self._reset_button,
])
os.system("sudo reboot")
def _backup_ssh_params(self):
if PC:
return
backup_dir = "/cache/reset_backup"
os.system(f"sudo rm -rf {backup_dir}")
os.system(f"sudo mkdir -p {backup_dir}")
for key in ("GithubSshKeys", "SshEnabled"):
os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true")
os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true")
gui_app.add_nav_stack_tick(self._nav_stack_tick)
def _do_erase(self):
if PC:
return
self._backup_ssh_params()
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
@@ -81,92 +112,42 @@ class Reset(Widget):
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
self._reset_failed = True
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _start_reset(self):
def do_erase_thread():
threading.Thread(target=self._do_erase, daemon=True).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
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._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
elif self._mode != ResetMode.RECOVER and active_widget != self._resetting_page and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50)
gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80)
gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9)
if self._reset_state != ResetState.RESETTING:
# fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel
self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage)
self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8)
if self._mode == ResetMode.RECOVER:
self._cancel_button.set_text("reboot")
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED:
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
if self._reset_state != ResetState.FAILED:
self._confirm_slider.render(rl.Rectangle(
rect.x + rect.width - self._confirm_slider.rect.width,
rect.y + rect.height - self._confirm_slider.rect.height,
self._confirm_slider.rect.width,
self._confirm_slider.rect.height))
else:
self._reboot_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._reboot_button.rect.height,
self._reboot_button.rect.width,
self._reboot_button.rect.height))
return self._render_status
def _confirm(self):
self.start_reset()
def _get_body_text(self):
if self._reset_state == ResetState.RESETTING:
return "Resetting device... This may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. It may be corrupted."
return "All content and settings will be erased."
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
elif sys.argv[1] == '--tap-reset':
mode = ResetMode.TAP_RESET
gui_app.init_window("System Reset")
reset = Reset(mode)
gui_app.push_widget(reset)
if mode == ResetMode.FORMAT:
reset.start_reset()
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)):
break
for _ in gui_app.render():
pass
if __name__ == "__main__":
Regular → Executable
+340 -523
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+111 -129
View File
@@ -3,179 +3,161 @@ import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from openpilot.system.hardware import HARDWARE
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.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel
from openpilot.system.ui.widgets.button import FullRoundedButton
from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor
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 Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
FAILED = 3
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 Updater(Widget):
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.current_screen = Screen.PROMPT
self._current_network_strength = -1
self.progress_value = 0
self.progress_text = "loading"
self.process = None
self.update_thread = None
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(True)
self._update_failed = False
self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback,
self._network_setup_back_callback)
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
self._network_monitor = NetworkConnectivityMonitor()
self._network_monitor.start()
# Buttons
self._continue_button = FullRoundedButton("continue")
self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI))
self._network_setup_page = UpdaterNetworkSetupPage(self._network_monitor, self._network_setup_continue_callback)
self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255),
font_weight=FontWeight.DISPLAY)
self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
self._progress_page = ProgressPage()
self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback,
title="update failed")
self._failed_page = FailedPage(self._retry, title="update failed")
def _network_setup_back_callback(self):
self.set_current_screen(Screen.PROMPT)
self._continue_button = BigPillButton("next")
self._continue_button.set_click_callback(lambda: gui_app.push_widget(self._network_setup_page))
def _network_setup_continue_callback(self):
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 _update_failed_retry_callback(self):
self.set_current_screen(Screen.PROMPT)
def _retry(self):
gui_app.pop_widgets_to(self)
def _on_network_updated(self, networks: list[Network]):
self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1)
def _nav_stack_tick(self):
self._progress_page.set_progress(self.progress_text, self.progress_value)
def set_current_screen(self, screen: Screen):
if self.current_screen != screen:
if screen == Screen.PROGRESS:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.WIFI:
if self._network_setup_page:
self._network_setup_page.show_event()
elif screen == Screen.PROMPT:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.FAILED:
if self._network_setup_page:
self._network_setup_page.hide_event()
self.current_screen = screen
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.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "downloading"
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def 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
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
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
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
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.set_current_screen(Screen.FAILED)
def render_prompt_screen(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y - 5,
rect.width,
48,
))
subtitle_width = rect.width - 16
subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width))
self._subtitle_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 48,
subtitle_width,
subtitle_height,
))
self._continue_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8)
if ' ' in self.progress_text:
font_size = 62
else:
font_size = 82
gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
progress_value = f"{self.progress_value}%"
text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y
progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18,
self._rect.width - 12, text_height)
gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN,
color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)))
def _update_state(self):
self._wifi_manager.process_callbacks()
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
self._network_setup_page.render(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
elif self.current_screen == Screen.FAILED:
self._update_failed_page.render(rect)
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)
@@ -186,9 +168,9 @@ def main():
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
gui_app.push_widget(updater)
for _ in gui_app.render():
pass
updater.close()
except Exception as e:
print(f"Updater error: {e}")
+2 -4
View File
@@ -1,13 +1,11 @@
#!/usr/bin/env python3
from openpilot.system.hardware import HARDWARE
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():
# Use actual hardware type, not UI scale/env flags, to choose reset UI.
# This prevents mici devices from launching tici reset layouts.
if HARDWARE.get_device_type() in ("tici", "tizi"):
if gui_app.big_ui():
tici_reset.main()
else:
mici_reset.main()
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
from openpilot.system.hardware import HARDWARE
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 HARDWARE.get_device_type() in ("tici", "tizi"):
if gui_app.big_ui():
tici_setup.main()
else:
mici_setup.main()
+1 -66
View File
@@ -1,12 +1,7 @@
#!/usr/bin/env python3
import os
import pyray as rl
import select
import subprocess
import sys
import time
from collections import deque
from pathlib import Path
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -31,28 +26,12 @@ MARGIN_H = 100
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
RESET_TAP_COUNT = 8
RESET_TAP_WINDOW_S = 4.0
# StarPilot variables
GREEN = (23, 134, 68, 242)
def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value)
def get_device_type() -> str:
model_path = Path("/sys/firmware/devicetree/base/model")
if model_path.is_file():
try:
model = model_path.read_text().strip("\x00")
return model.split("comma ")[-1].strip().lower()
except Exception:
pass
return ""
class Spinner(Widget):
def __init__(self):
super().__init__()
@@ -61,10 +40,6 @@ class Spinner(Widget):
self._rotation = 0.0
self._progress: int | None = None
self._wrapped_lines: list[str] = []
self._logo_rect = rl.Rectangle(0, 0, 0, 0)
self._tap_times = deque(maxlen=RESET_TAP_COUNT)
self._launch_reset = False
self._allow_reset_gesture = os.path.isfile("/TICI") and get_device_type() not in ("tici", "tizi")
def set_text(self, text: str) -> None:
if text.isdigit():
@@ -89,7 +64,6 @@ class Spinner(Widget):
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)
self._logo_rect = rl.Rectangle(comma_position.x, comma_position.y, TEXTURE_SIZE, TEXTURE_SIZE)
delta_time = rl.get_frame_time()
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
@@ -106,30 +80,13 @@ class Spinner(Widget):
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
bar.width *= self._progress / 100.0
rl.draw_rectangle_rounded(bar, 1, 10, GREEN)
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 _handle_mouse_release(self, mouse_pos):
if not self._allow_reset_gesture:
return
if not rl.check_collision_point_rec(mouse_pos, self._logo_rect):
return
now = time.monotonic()
self._tap_times.append(now)
if len(self._tap_times) == RESET_TAP_COUNT and (now - self._tap_times[0]) <= RESET_TAP_WINDOW_S:
self._tap_times.clear()
self._launch_reset = True
@property
def should_launch_reset(self) -> bool:
return self._launch_reset
def _read_stdin():
"""Non-blocking read of available lines from stdin."""
@@ -154,28 +111,6 @@ def main():
spinner.set_text(text_list[-1])
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if spinner.should_launch_reset:
reset_script = Path(__file__).with_name("reset.py")
try:
proc = subprocess.Popen(
[sys.executable, str(reset_script)],
cwd=str(reset_script.parent),
close_fds=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
spinner.set_text("Failed to launch reset UI")
continue
# Keep spinner alive if reset process exits immediately (prevents blank screen).
time.sleep(0.2)
if proc.poll() is not None:
spinner.set_text("Reset UI failed to start")
continue
gui_app.request_close()
break
if __name__ == "__main__":
Regular → Executable
+18 -53
View File
@@ -7,21 +7,19 @@ 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
NVME = "/dev/nvme0n1"
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON"))
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
@@ -37,35 +35,14 @@ class Reset(Widget):
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._cancel_button = Button("Cancel", self._cancel_callback)
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"))
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _backup_ssh_params(self):
if PC:
return
backup_dir = "/cache/reset_backup"
os.system(f"sudo rm -rf {backup_dir}")
os.system(f"sudo mkdir -p {backup_dir}")
for key in ("GithubSshKeys", "SshEnabled"):
os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true")
os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true")
def _do_erase(self):
if PC:
return
self._backup_ssh_params()
# Best effort to wipe NVME
os.system(f"sudo umount {NVME}")
os.system(f"yes | sudo mkfs.ext4 {NVME}")
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
@@ -76,7 +53,7 @@ class Reset(Widget):
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
def _start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
@@ -87,34 +64,34 @@ class Reset(Widget):
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
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(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
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 = rect.y + rect.height - button_height
button_width = (rect.width - button_spacing) / 2.0
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(rect.x, button_top, button_width, button_height))
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(rect.x, button_top, button_width, button_height))
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(rect.x + button_width + 50, button_top, button_width, button_height))
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(rect.x, button_top, rect.width, button_height))
return self._render_status
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()
self._start_reset()
else:
self._reset_state = ResetState.CONFIRM
@@ -131,30 +108,18 @@ class Reset(Widget):
def main():
# Safety fallback: if this module is launched on a small-UI device,
# hand off to the mici reset implementation to avoid off-screen layout.
if not gui_app.big_ui():
import openpilot.system.ui.mici_reset as mici_reset
mici_reset.main()
return
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset", 20)
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
gui_app.push_widget(reset)
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
break
for _ in gui_app.render():
pass
if __name__ == "__main__":
Regular → Executable
+17 -50
View File
@@ -7,16 +7,14 @@ import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
import pyray as rl
from cereal import log
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets 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
@@ -32,24 +30,12 @@ BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot"
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class SetupState(IntEnum):
LOW_VOLTAGE = 0
@@ -93,7 +79,7 @@ class Setup(Widget):
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("StarPilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
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)
@@ -177,7 +163,9 @@ class Setup(Widget):
def _software_selection_continue_button_callback(self):
if self._software_selection_openpilot_button.selected:
self.use_openpilot()
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
else:
self.state = SetupState.CUSTOM_SOFTWARE_WARNING
@@ -190,12 +178,12 @@ class Setup(Widget):
def _network_setup_continue_button_callback(self):
self.stop_network_check_thread.set()
if self._software_selection_openpilot_button.selected:
self.download(DEFAULT_INSTALLER_URL)
self.download(OPENPILOT_URL)
else:
self.state = SetupState.CUSTOM_SOFTWARE
def render_low_voltage(self, rect: rl.Rectangle):
rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
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))
@@ -219,7 +207,7 @@ class Setup(Widget):
while not self.stop_network_check_thread.is_set():
if self.state == SetupState.NETWORK_SETUP:
try:
urllib.request.urlopen(NETWORK_CHECK_URL, timeout=2)
urllib.request.urlopen(OPENPILOT_URL, timeout=2.0)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
@@ -227,7 +215,7 @@ class Setup(Widget):
self.wifi_connected.clear()
except Exception:
self.network_connected.clear()
time.sleep(1)
time.sleep(1.0)
def start_network_check(self):
if self.network_check_thread is None or not self.network_check_thread.is_alive():
@@ -328,38 +316,20 @@ class Setup(Widget):
def render_custom_software(self):
def handle_keyboard_result(result):
# Enter pressed
if result == 1:
if result == DialogResult.CONFIRM:
url = self.keyboard.text
self.keyboard.clear()
if url:
self.download(url)
# Cancel pressed
elif result == 0:
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")
gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(DEFAULT_INSTALLER_URL)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
self.keyboard.set_callback(handle_keyboard_result)
gui_app.push_widget(self.keyboard)
def download(self, url: str):
# autocomplete incomplete URLs
@@ -418,9 +388,6 @@ class Setup(Widget):
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
if os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
@@ -443,9 +410,9 @@ def main():
try:
gui_app.init_window("Setup", 20)
setup = Setup()
for should_render in gui_app.render():
if should_render:
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
gui_app.push_widget(setup)
for _ in gui_app.render():
pass
setup.close()
except Exception as e:
print(f"Setup error: {e}")
Regular → Executable
+20 -15
View File
@@ -67,18 +67,24 @@ class Updater(Widget):
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
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
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
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:
@@ -160,10 +166,9 @@ def main():
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
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()
+1 -1
View File
@@ -28,7 +28,7 @@ def _ui_device_type() -> str:
def main():
device_type = _ui_device_type()
# The updater stack imports application sizing during module import, so patch the
# The updater imports application sizing during module import, so patch the
# hardware probe before importing either UI implementation.
HARDWARE.get_device_type = lambda: device_type
+65 -238
View File
@@ -1,18 +1,22 @@
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.common.filter_simple import BounceFilter, FirstOrderFilter
from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
try:
from openpilot.selfdrive.ui.ui_state import device
except ImportError:
class Device:
awake = True
device = Device()
device = Device() # type: ignore
W = TypeVar('W', bound='Widget')
DEBUG = False
class DialogResult(IntEnum):
@@ -25,23 +29,28 @@ 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._enabled: bool | Callable[[], bool] = True
self._is_visible: bool | Callable[[], bool] = True
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
self._children: list = []
@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
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()
@@ -52,21 +61,8 @@ class Widget(abc.ABC):
@property
def is_pressed(self) -> bool:
return any(self.__is_pressed)
@property
def _is_pressed(self) -> bool:
return any(self.__is_pressed)
@_is_pressed.setter
def _is_pressed(self, value: bool):
if value:
for i, tracked in enumerate(self._Widget__tracking_is_pressed):
if tracked:
self.__is_pressed[i] = True
else:
for i in range(len(self.__is_pressed)):
self.__is_pressed[i] = False
# 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:
@@ -95,7 +91,7 @@ class Widget(abc.ABC):
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
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()
@@ -107,26 +103,40 @@ class Widget(abc.ABC):
return self._rect
return rl.get_collision_rec(self._rect, self._parent_rect)
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
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()
@@ -186,6 +196,8 @@ class Widget(abc.ABC):
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()
@@ -193,225 +205,40 @@ class Widget(abc.ABC):
"""Optionally handle mouse events. This is called before rendering."""
# Default implementation does nothing, can be overridden by subclasses
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
for child in self._children:
child.show_event()
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
for child in self._children:
child.hide_event()
def _child(self, widget):
"""Register a child widget for lifecycle propagation."""
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):
"""Dismiss this widget from the nav stack."""
"""Immediately dismiss the widget, firing the callback after."""
gui_app.pop_widget()
if callback:
callback()
SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
NAV_BAR_MARGIN = 6
NAV_BAR_WIDTH = 205
NAV_BAR_HEIGHT = 8
DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing
DISMISS_TIME_SECONDS = 1.5
class NavBar(Widget):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT))
self._alpha = 1.0
self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._fade_time = 0.0
def set_alpha(self, alpha: float) -> None:
self._alpha = alpha
self._fade_time = rl.get_time()
def show_event(self):
super().show_event()
self._alpha = 1.0
self._alpha_filter.x = 1.0
self._fade_time = rl.get_time()
def _render(self, _):
if rl.get_time() - self._fade_time > DISMISS_TIME_SECONDS:
self._alpha = 0.0
alpha = self._alpha_filter.update(self._alpha)
# white bar with black border
rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha)))
rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha)))
class NavWidget(Widget, abc.ABC):
"""
A full screen widget that supports back navigation by swiping down from the top.
"""
BACK_TOUCH_AREA_PERCENTAGE = 0.65
def __init__(self):
super().__init__()
self._back_callback: Callable[[], None] | None = None
self._back_button_start_pos: MousePos | None = None
self._swiping_away = False # currently swiping away
self._can_swipe_away = True # swipe away is blocked after certain horizontal movement
self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
self._playing_dismiss_animation = False
self._trigger_animate_in = False
self._back_enabled: bool | Callable[[], bool] = True
self._nav_bar = NavBar()
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._set_up = False
@property
def back_enabled(self) -> bool:
return self._back_enabled() if callable(self._back_enabled) else self._back_enabled
def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._back_enabled = enabled
def set_back_callback(self, callback: Callable[[], None]) -> None:
self._back_callback = callback
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if not self.back_enabled:
self._back_button_start_pos = None
self._swiping_away = False
self._can_swipe_away = True
return
if mouse_event.left_pressed:
# user is able to swipe away if starting near top of screen, or anywhere if scroller is at top
self._pos_filter.update_alpha(0.04)
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
scroller_at_top = False
vertical_scroller = False
# TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
if hasattr(self, '_scroller'):
scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal
vertical_scroller = not self._scroller._horizontal
elif hasattr(self, '_scroll_panel'):
scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
vertical_scroller = not self._scroll_panel._horizontal
# Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes
if (not vertical_scroller and in_dismiss_area) or scroller_at_top:
self._can_swipe_away = True
self._back_button_start_pos = mouse_event.pos
elif mouse_event.left_down:
if self._back_button_start_pos is not None:
# block swiping away if too much horizontal or upward movement
horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
if not self._swiping_away and (horizontal_movement or upward_movement):
self._can_swipe_away = False
self._back_button_start_pos = None
# block horizontal swiping if now swiping away
if self._can_swipe_away:
if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore
self._swiping_away = True
elif mouse_event.left_released:
self._pos_filter.update_alpha(0.1)
# if far enough, trigger back navigation callback
if self._back_button_start_pos is not None:
if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD:
self._playing_dismiss_animation = True
self._back_button_start_pos = None
self._swiping_away = False
def _update_state(self):
super()._update_state()
# Disable self's scroller while swiping away
if not self._set_up:
self._set_up = True
if hasattr(self, '_scroller'):
original_enabled = self._scroller._enabled
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
elif hasattr(self, '_scroll_panel'):
original_enabled = self._scroll_panel.enabled
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
if self._trigger_animate_in:
self._pos_filter.x = self._rect.height
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
self._trigger_animate_in = False
new_y = 0.0
if self._back_button_start_pos is not None:
last_mouse_event = gui_app.last_mouse_event
# push entire widget as user drags it away
new_y = max(last_mouse_event.pos.y - self._back_button_start_pos.y, 0)
if new_y < SWIPE_AWAY_THRESHOLD:
new_y /= 2 # resistance until mouse release would dismiss widget
if self._swiping_away:
self._nav_bar.set_alpha(1.0)
if self._playing_dismiss_animation:
new_y = self._rect.height + DISMISS_PUSH_OFFSET
new_y = round(self._pos_filter.update(new_y))
if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0:
new_y = self._pos_filter.x = 0.0
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
if self._back_callback is not None:
self._back_callback()
self._playing_dismiss_animation = False
self._back_button_start_pos = None
self._swiping_away = False
self.set_position(self._rect.x, new_y)
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
ret = super().render(rect)
if self.back_enabled:
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
if self._back_button_start_pos is not None or self._playing_dismiss_animation:
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x
else:
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
self._nav_bar.render()
# draw black above widget when dismissing
if self._rect.y > 0:
rl.draw_rectangle(int(self._rect.x), 0, int(self._rect.width), int(self._rect.y), rl.BLACK)
return ret
def show_event(self):
super().show_event()
# FIXME: we don't know the height of the rect at first show_event since it's before the first render :(
# so we need this hacky bool for now
self._trigger_animate_in = True
self._nav_bar.show_event()
+4 -82
View File
@@ -5,7 +5,7 @@ import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import Label, UnifiedLabel
from openpilot.system.ui.widgets.label import Label
from openpilot.common.filter_simple import FirstOrderFilter
@@ -191,7 +191,7 @@ class IconButton(Widget):
color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(self._texture, int(draw_x), int(draw_y), color)
rl.draw_texture_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, color)
class SmallCircleIconButton(Widget):
@@ -219,85 +219,7 @@ class SmallCircleIconButton(Widget):
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
icon_white = white
rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white)
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(self._icon_txt, int(icon_x), int(icon_y), icon_white)
class SmallButton(Widget):
def __init__(self, text: str):
super().__init__()
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._load_assets()
self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._bg_disabled_txt = None
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
def set_text(self, text: str):
self._label.set_text(text)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
if not self.enabled and self._bg_disabled_txt is not None:
rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
elif self.is_pressed:
rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
else:
rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
opacity = 0.9 if self.enabled else 0.35
self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
self._label.render(self._rect)
class SmallRedPillButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
class SmallerRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 150, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
class WideRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 316, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
class WidishRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 250, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
class FullRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)
rl.draw_texture_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white)
+11 -18
View File
@@ -1,5 +1,5 @@
from collections.abc import Callable
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
@@ -18,7 +18,7 @@ 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, on_close: Callable[[DialogResult], None] | None = None):
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")
@@ -27,8 +27,7 @@ class ConfirmDialog(Widget):
self._cancel_button = Button(cancel_text, self._cancel_button_callback)
self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._rich = rich
self._dialog_result = DialogResult.NO_ACTION
self._on_close = on_close
self._callback = callback
self._cancel_text = cancel_text
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
@@ -38,17 +37,15 @@ class ConfirmDialog(Widget):
else:
self._html_renderer.parse_html_content(text)
def reset(self):
self._dialog_result = DialogResult.NO_ACTION
self._on_close = on_close
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
if self._on_close: self._on_close(self._dialog_result)
gui_app.pop_widget()
if self._callback:
self._callback(DialogResult.CANCEL)
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
if self._on_close: self._on_close(self._dialog_result)
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
@@ -78,11 +75,9 @@ class ConfirmDialog(Widget):
self._scroller.render(text_rect)
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
self._dialog_result = DialogResult.CONFIRM
if self._on_close: self._on_close(self._dialog_result)
self._confirm_button_callback()
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
self._dialog_result = DialogResult.CANCEL
if self._on_close: self._on_close(self._dialog_result)
self._cancel_button_callback()
if self._cancel_text:
self._confirm_button.render(confirm_button)
@@ -92,8 +87,6 @@ class ConfirmDialog(Widget):
full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT)
self._confirm_button.render(full_confirm_button)
return self._dialog_result
def alert_dialog(message: str, button_text: str | None = None):
if button_text is None:
+1 -1
View File
@@ -260,7 +260,7 @@ class HtmlModal(Widget):
super().__init__()
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel()
self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY)
self._ok_button = Button(tr("OK"), click_callback=gui_app.pop_widget, button_style=ButtonStyle.PRIMARY)
def _render(self, rect: rl.Rectangle):
margin = 50
+16
View File
@@ -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)
-43
View File
@@ -1,43 +0,0 @@
from collections.abc import Callable
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
class InputDialog(Widget):
def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None):
super().__init__()
self._default_text = default_text
self._on_close = on_close
self._dialog_result = DialogResult.NO_ACTION
self._keyboard = Keyboard(callback=self._on_keyboard_result)
self._keyboard.set_title(title)
self._keyboard.set_text(default_text)
def _on_keyboard_result(self, result: DialogResult):
if self._dialog_result != DialogResult.NO_ACTION:
return
self._dialog_result = result
if self._on_close:
self._on_close(result, self._keyboard.text)
@property
def result(self) -> DialogResult:
return self._dialog_result
@property
def text(self) -> str:
return self._keyboard.text
def show_event(self):
super().show_event()
self._dialog_result = DialogResult.NO_ACTION
self._keyboard.show_event()
self._keyboard.clear()
if self._default_text:
self._keyboard.set_text(self._default_text)
def _render(self, rect):
self._keyboard.render(rect)
return self._dialog_result
+26 -56
View File
@@ -7,7 +7,7 @@ import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
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
@@ -59,14 +59,8 @@ KEYBOARD_LAYOUTS = {
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,
):
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
@@ -86,9 +80,6 @@ class Keyboard(Widget):
self._backspace_press_time: float = 0.0
self._backspace_last_repeat: float = 0.0
self._render_return_status = -1
self._first_render = False
self._skip_input = False
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
@@ -109,18 +100,12 @@ class Keyboard(Widget):
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,
)
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
)
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
@@ -142,39 +127,24 @@ class Keyboard(Widget):
def set_callback(self, callback: Callable[[DialogResult], None] | None):
self._callback = callback
def show_event(self):
super().show_event()
self._skip_input = True
def _process_mouse_events(self):
if not self._skip_input:
super()._process_mouse_events()
def _cancel_button_callback(self):
self.clear()
if self in gui_app._nav_stack:
gui_app.pop_widget()
else:
self._render_return_status = 0
if self._callback:
self._callback(DialogResult.CANCEL)
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:
if self in gui_app._nav_stack:
gui_app.pop_widget()
else:
self._render_return_status = 1
gui_app.pop_widget()
if self._callback:
self._callback(DialogResult.CONFIRM)
else:
self.handle_key_press(k)
def _render(self, rect: rl.Rectangle):
self._skip_input = False
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))
@@ -236,8 +206,6 @@ class Keyboard(Widget):
self._all_keys[key].set_enabled(is_enabled)
self._all_keys[key].render(key_rect)
return self._render_return_status
def _render_input_area(self, input_rect: rl.Rectangle):
if self._show_password_toggle:
self._input_box.set_password_mode(self._password_mode)
@@ -289,7 +257,6 @@ class Keyboard(Widget):
def reset(self, min_text_size: int | None = None):
if min_text_size is not None:
self._min_text_size = min_text_size
self._render_return_status = -1
self._last_shift_press_time = 0
self._backspace_pressed = False
self._backspace_press_time = 0.0
@@ -298,15 +265,18 @@ class Keyboard(Widget):
if __name__ == "__main__":
gui_app.init_window("Keyboard")
keyboard = Keyboard(min_text_size=8, show_password_toggle=True)
for _ in gui_app.render():
keyboard.set_title("Keyboard Input", "Type your text below")
result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result == 1:
def callback(result: DialogResult):
if result == DialogResult.CONFIRM:
print(f"You typed: {keyboard.text}")
gui_app.request_close()
elif result == 0:
elif result == DialogResult.CANCEL:
print("Canceled")
gui_app.request_close()
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()
+75 -202
View File
@@ -27,166 +27,6 @@ class ScrollState(IntEnum):
SCROLLING = 1
# TODO: merge anything new here to master
class MiciLabel(Widget):
def __init__(self,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
width: int = None,
color: rl.Color = DEFAULT_TEXT_COLOR,
font_weight: FontWeight = FontWeight.NORMAL,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
spacing: int = 0,
line_height: int = None,
elide_right: bool = True,
wrap_text: bool = False,
scroll: bool = False):
super().__init__()
self.text = text
self.wrapped_text: list[str] = []
self.font_size = font_size
self.width = width
self.color = color
self.font_weight = font_weight
self.alignment = alignment
self.alignment_vertical = alignment_vertical
self.spacing = spacing
self.line_height = line_height if line_height is not None else font_size
self.elide_right = elide_right
self.wrap_text = wrap_text
self._height = 0
# Scroll state
self.scroll = scroll
self._needs_scroll = False
self._scroll_offset = 0
self._scroll_pause_t: float | None = None
self._scroll_state: ScrollState = ScrollState.STARTING
assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
self.set_text(text)
@property
def text_height(self):
return self._height
def set_font_size(self, font_size: int):
self.font_size = font_size
self.set_text(self.text)
def set_width(self, width: int):
self.width = width
self._rect.width = width
self.set_text(self.text)
def set_text(self, txt: str):
self.text = txt
text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
if self.width is not None:
self._rect.width = self.width
else:
self._rect.width = text_size.x
if self.wrap_text:
self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
self._height = len(self.wrapped_text) * self.line_height
elif self.scroll:
self._needs_scroll = self.scroll and text_size.x > self._rect.width
self._rect.height = text_size.y
def set_color(self, color: rl.Color):
self.color = color
def set_font_weight(self, font_weight: FontWeight):
self.font_weight = font_weight
self.set_text(self.text)
def _render(self, rect: rl.Rectangle):
# Only scissor when we know there is a single scrolling line
if self._needs_scroll:
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
font = gui_app.font(self.font_weight)
text_y_offset = 0
# Draw the text in the specified rectangle
lines = self.wrapped_text or [self.text]
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
lines = lines[::-1]
for display_text in lines:
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
# Elide text to fit within the rectangle
if self.elide_right and text_size.x > rect.width:
ellipsis = "..."
left, right = 0, len(display_text)
while left < right:
mid = (left + right) // 2
candidate = display_text[:mid] + ellipsis
candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
if candidate_size.x <= rect.width:
left = mid + 1
else:
right = mid
display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
# Handle scroll state
elif self.scroll and self._needs_scroll:
if self._scroll_state == ScrollState.STARTING:
if self._scroll_pause_t is None:
self._scroll_pause_t = rl.get_time() + 2.0
if rl.get_time() >= self._scroll_pause_t:
self._scroll_state = ScrollState.SCROLLING
self._scroll_pause_t = None
elif self._scroll_state == ScrollState.SCROLLING:
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
# don't fully hide
if self._scroll_offset <= -text_size.x - self._rect.width / 3:
self._scroll_offset = 0
self._scroll_state = ScrollState.STARTING
self._scroll_pause_t = None
# Calculate horizontal position based on alignment
text_x = rect.x + {
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
}.get(self.alignment, 0) + self._scroll_offset
# Calculate vertical position based on alignment
text_y = rect.y + {
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
}.get(self.alignment_vertical, 0)
text_y += text_y_offset
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), round(text_y)), self.font_size, self.spacing, self.color)
# Draw 2nd instance for scrolling
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
text2_scroll_offset = text_size.x + self._rect.width / 3
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), round(text_y)), self.font_size, self.spacing, self.color)
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
text_y_offset -= self.line_height
else:
text_y_offset += self.line_height
if self._needs_scroll:
# draw black fade on left and right
fade_width = 20
rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
if self._scroll_state != ScrollState.STARTING:
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)
rl.end_scissor_mode()
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
@@ -233,7 +73,7 @@ def gui_label(
# 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(round(text_x), round(text_y)), font_size, 0, color)
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
def gui_text_box(
@@ -393,7 +233,7 @@ class Label(Widget):
class UnifiedLabel(Widget):
"""
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
Unified label widget that combines functionality from gui_label, gui_text_box, and Label.
Supports:
- Emoji rendering
@@ -402,11 +242,12 @@ class UnifiedLabel(Widget):
- Proper multiline vertical alignment
- Height calculation for layout purposes
"""
SHIMMER_BAND_WIDTH = 0.3
SHIMMER_BLUR_RADIUS = 0.12
SHIMMER_CYCLE_PERIOD = 2.5
SHIMMER_SWEEP_FRACTION = 0.9
SHIMMER_LOW_OPACITY = 0.65
# 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],
@@ -439,6 +280,8 @@ class UnifiedLabel(Widget):
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
@@ -477,6 +320,14 @@ class UnifiedLabel(Widget):
"""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
@@ -504,7 +355,7 @@ class UnifiedLabel(Widget):
new_line_height = line_height * 0.9
if self._line_height != new_line_height:
self._line_height = new_line_height
self._cached_text = None
self._cached_text = None # Invalidate cache (affects total height)
def set_font_weight(self, font_weight: FontWeight):
"""Update the font weight."""
@@ -527,7 +378,13 @@ class UnifiedLabel(Widget):
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):
@@ -647,25 +504,6 @@ class UnifiedLabel(Widget):
return self._cached_total_height
return 0.0
def _compute_shimmer_alpha(self, char_center_x: float, text_left: float, text_width: float) -> float:
if text_width <= 0:
return self.SHIMMER_LOW_OPACITY
elapsed = rl.get_time() - self._shimmer_start_time
sigma = text_width * self.SHIMMER_BLUR_RADIUS
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)
margin = text_width * self.SHIMMER_BAND_WIDTH
text_right = text_left + text_width
center = text_right + margin - t * (text_width + 2.0 * margin)
d = char_center_x - center
shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) if sigma > 0 else 0.0
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
def _render(self, _):
"""Render the label."""
if self._rect.width <= 0 or self._rect.height <= 0:
@@ -792,11 +630,34 @@ class UnifiedLabel(Widget):
# draw black fade on left and right
fade_width = 20
rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - fade_width), int(self._rect.y), fade_width, int(self._rect.height), rl.BLANK, rl.BLACK)
if self._scroll_state != ScrollState.STARTING:
# 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:
@@ -809,21 +670,13 @@ class UnifiedLabel(Widget):
line_x = self._rect.x + self._text_padding
line_x += self._scroll_offset + x_offset
if self._shimmer and not emojis and line:
base_alpha = self._text_color.a / 255.0
text_width = max(size.x, 1.0)
cursor_x = line_x
for char in line:
char_width = measure_text_cached(self._font, char, self._font_size, self._spacing_pixels).x
char_center_x = cursor_x + char_width / 2.0
shimmer_alpha = self._compute_shimmer_alpha(char_center_x, line_x, text_width)
char_alpha = int(255 * base_alpha * shimmer_alpha)
char_color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, char_alpha)
rl.draw_text_ex(self._font, char, rl.Vector2(cursor_x, current_y), self._font_size, self._spacing_pixels, char_color)
cursor_x += char_width
return
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)
# Render line with emojis
def _render_line_normal(self, line, emojis, line_x, current_y):
line_pos = rl.Vector2(line_x, current_y)
prev_index = 0
@@ -847,3 +700,23 @@ class UnifiedLabel(Widget):
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
+59
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+35 -21
View File
@@ -38,10 +38,10 @@ def fast_euclidean_distance(dx, dy):
class Key(Widget):
def __init__(self, char: str):
def __init__(self, char: str, font_weight: FontWeight = FontWeight.SEMI_BOLD):
super().__init__()
self.char = char
self._font = gui_app.font(FontWeight.SEMI_BOLD)
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)
@@ -53,20 +53,23 @@ class Key(Widget):
self.original_position = rl.Vector2(0, 0)
def set_position(self, x: float, y: float, smooth: bool = True):
# TODO: swipe up from NavWidget has the keys lag behind other elements a bit
# 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 = y
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, y + KEY_TOUCH_AREA_OFFSET)
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 = y
self._y_filter.x = local_y
self._rect.x = self._x_filter.update(x)
self._rect.y = self._y_filter.update(y)
self._rect.y = base_y + self._y_filter.update(local_y)
def set_alpha(self, alpha: float):
self._alpha_filter.update(alpha)
@@ -92,12 +95,12 @@ class Key(Widget):
self._size_filter.update(size)
def _get_font_size(self) -> int:
return int(round(self._size_filter.x))
return round(self._size_filter.x)
class SmallKey(Key):
def __init__(self, chars: str):
super().__init__(chars)
super().__init__(chars, FontWeight.BOLD)
self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
def set_font_size(self, size: float):
@@ -105,13 +108,15 @@ class SmallKey(Key):
class IconKey(Key):
def __init__(self, icon: str, vertical_align: str = "center", char: str = ""):
def __init__(self, icon: str, vertical_align: str = "center", char: str = "", icon_size: tuple[int, int] = (38, 38)):
super().__init__(char)
self._icon = gui_app.texture(icon, 38, 38)
self._icon_size = icon_size
self._icon = gui_app.texture(icon, *icon_size)
self._vertical_align = vertical_align
def set_icon(self, icon: str):
self._icon = gui_app.texture(icon, 38, 38)
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])
@@ -141,8 +146,9 @@ class CapsState(IntEnum):
class MiciKeyboard(Widget):
def __init__(self):
def __init__(self, auto_return_to_letters: str = ""):
super().__init__()
self._auto_return_to_letters = auto_return_to_letters
lower_chars = [
"qwertyuiop",
@@ -167,8 +173,8 @@ class MiciKeyboard(Widget):
self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
# control keys
self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom")
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png")
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")
@@ -222,6 +228,8 @@ class MiciKeyboard(Widget):
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)
@@ -259,7 +267,8 @@ class MiciKeyboard(Widget):
for key in row:
mouse_pos = gui_app.last_mouse_event.pos
# approximate distance for comparison is accurate enough
dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y)
# 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)
@@ -269,14 +278,14 @@ class MiciKeyboard(Widget):
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")
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")
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")
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png", icon_size=(39, 38))
else:
self._set_uppercase(False)
@@ -297,6 +306,10 @@ class MiciKeyboard(Widget):
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()
@@ -314,7 +327,7 @@ class MiciKeyboard(Widget):
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:
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
@@ -365,6 +378,7 @@ class MiciKeyboard(Widget):
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, _):
+229
View File
@@ -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()
+50 -43
View File
@@ -6,8 +6,8 @@ import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.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
@@ -22,8 +22,8 @@ try:
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
except Exception:
Params = None
ui_state = None # type: ignore
PrimeType = None # type: ignore
ui_state = None
PrimeType = None
NM_DEVICE_STATE_NEED_AUTH = 60
MIN_PASSWORD_LENGTH = 8
@@ -69,17 +69,14 @@ class NetworkUI(Widget):
super().__init__()
self._wifi_manager = wifi_manager
self._current_panel: PanelType = PanelType.WIFI
self._wifi_panel = WifiManagerUI(wifi_manager)
self._advanced_panel = AdvancedNetworkSettings(wifi_manager)
self._nav_button = NavButton(tr("Advanced"))
self._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)
self._wifi_panel.show_event()
def hide_event(self):
self._wifi_panel.hide_event()
def _cycle_panel(self):
if self._current_panel == PanelType.WIFI:
@@ -187,8 +184,8 @@ class AdvancedNetworkSettings(Widget):
self._wifi_manager.update_gsm_settings(roaming_state, self._params.get("GsmApn") or "", self._params.get_bool("GsmMetered"))
def _edit_apn(self):
def update_apn(result):
if result != 1:
def update_apn(result: DialogResult):
if result != DialogResult.CONFIRM:
return
apn = self._keyboard.text.strip()
@@ -203,7 +200,8 @@ class AdvancedNetworkSettings(Widget):
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration"))
self._keyboard.set_text(current_apn)
gui_app.set_modal_overlay(self._keyboard, update_apn)
self._keyboard.set_callback(update_apn)
gui_app.push_widget(self._keyboard)
def _toggle_cellular_metered(self):
metered = self._cellular_metered_action.get_state()
@@ -216,15 +214,18 @@ class AdvancedNetworkSettings(Widget):
self._wifi_manager.set_current_network_metered(metered_type)
def _connect_to_hidden_network(self):
def connect_hidden(result):
if result != 1:
def connect_hidden(result: DialogResult):
if result != DialogResult.CONFIRM:
return
ssid = self._keyboard.text
if not ssid:
return
def enter_password(result):
def enter_password(result: DialogResult):
if result != DialogResult.CONFIRM:
return
password = self._keyboard.text
if password == "":
# connect without password
@@ -235,15 +236,17 @@ class AdvancedNetworkSettings(Widget):
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid))
gui_app.set_modal_overlay(self._keyboard, enter_password)
self._keyboard.set_callback(enter_password)
gui_app.push_widget(self._keyboard)
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Enter SSID"), "")
gui_app.set_modal_overlay(self._keyboard, connect_hidden)
self._keyboard.set_callback(connect_hidden)
gui_app.push_widget(self._keyboard)
def _edit_tethering_password(self):
def update_password(result):
if result != 1:
def update_password(result: DialogResult):
if result != DialogResult.CONFIRM:
return
password = self._keyboard.text
@@ -253,7 +256,8 @@ class AdvancedNetworkSettings(Widget):
self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
self._keyboard.set_title(tr("Enter new tethering password"), "")
self._keyboard.set_text(self._wifi_manager.tethering_password)
gui_app.set_modal_overlay(self._keyboard, update_password)
self._keyboard.set_callback(update_password)
gui_app.push_widget(self._keyboard)
def _update_state(self):
self._wifi_manager.process_callbacks()
@@ -292,10 +296,12 @@ class WifiManagerUI(Widget):
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):
@@ -311,31 +317,32 @@ class WifiManagerUI(Widget):
return
if self.state == UIState.NEEDS_AUTH and self._state_network:
self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"), tr("for \"{}\"").format(self._state_network.ssid))
self.keyboard.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)
gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result))
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"))
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid))
confirm_dialog.reset()
gui_app.set_modal_overlay(confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
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: int):
if result == 1:
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 == 0:
elif result == DialogResult.CANCEL:
self.state = UIState.IDLE
def on_forgot_confirm_finished(self, network, result: int):
if result == 1:
def on_forgot_confirm_finished(self, network, result: DialogResult):
if result == DialogResult.CONFIRM:
self.forget_network(network)
elif result == 0:
elif result == DialogResult.CANCEL:
self.state = UIState.IDLE
def _draw_network_list(self, rect: rl.Rectangle):
@@ -383,7 +390,7 @@ class WifiManagerUI(Widget):
gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
else:
# If the network is saved, show the "Forget" button
if network.is_saved:
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,
@@ -396,11 +403,11 @@ class WifiManagerUI(Widget):
self._draw_signal_strength_icon(signal_icon_rect, network)
def _networks_buttons_callback(self, network):
if not network.is_saved and network.security_type != SecurityType.OPEN:
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 not network.is_connected:
elif self._wifi_manager.wifi_state.ssid != network.ssid:
self.connect_to_network(network)
def _forget_networks_buttons_callback(self, network):
@@ -410,7 +417,7 @@ class WifiManagerUI(Widget):
def _draw_status_icon(self, rect, network: Network):
"""Draw the status icon based on network's connection state"""
icon_file = None
if network.is_connected and self.state != UIState.CONNECTING:
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"
@@ -432,7 +439,7 @@ class WifiManagerUI(Widget):
def connect_to_network(self, network: Network, password=''):
self.state = UIState.CONNECTING
self._state_network = network
if network.is_saved and not password:
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)
@@ -445,7 +452,7 @@ class WifiManagerUI(Widget):
def _on_network_updated(self, networks: list[Network]):
self._networks = networks
for n in self._networks:
self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55,
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,
@@ -463,7 +470,7 @@ class WifiManagerUI(Widget):
if self.state == UIState.CONNECTING:
self.state = UIState.IDLE
def _on_forgotten(self):
def _on_forgotten(self, _):
if self.state == UIState.FORGETTING:
self.state = UIState.IDLE
@@ -474,10 +481,10 @@ class WifiManagerUI(Widget):
def main():
gui_app.init_window("Wi-Fi Manager")
wifi_ui = WifiManagerUI(WifiManager())
gui_app.push_widget(WifiManagerUI(WifiManager()))
for _ in gui_app.render():
wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100))
pass
gui_app.close()
+7 -6
View File
@@ -1,5 +1,6 @@
import pyray as rl
from openpilot.system.ui.lib.application import FontWeight
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
@@ -17,13 +18,13 @@ LIST_ITEM_SPACING = 25
class MultiOptionDialog(Widget):
def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM):
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._result: DialogResult = DialogResult.NO_ACTION
self._callback = callback
# Create scroller with option buttons
self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt),
@@ -36,7 +37,9 @@ class MultiOptionDialog(Widget):
self.select_button = Button(lambda: tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY)
def _set_result(self, result: DialogResult):
self._result = result
gui_app.pop_widget()
if self._callback:
self._callback(result)
def _on_option_clicked(self, option):
self.selection = option
@@ -74,5 +77,3 @@ class MultiOptionDialog(Widget):
select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT)
self.select_button.set_enabled(self.selection != self.current)
self.select_button.render(select_rect)
return self._result
+228 -102
View File
@@ -3,38 +3,31 @@ 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
SCROLL_BAR = False
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
LINE_COLOR)
class 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)
@@ -48,23 +41,23 @@ class ScrollIndicator(Widget):
self._viewport = viewport
def _render(self, _):
if self._viewport.width <= 0 or self._viewport.height <= 0:
return
# scale indicator width based on content size
indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100]))
indicator_w = min(float(np.interp(self._content_size, [1000, 3000], [300, 100])), self._viewport.width)
# position based on scroll ratio
slide_range = self._viewport.width - indicator_w
max_scroll = self._content_size - self._viewport.width
if max_scroll > 0:
scroll_ratio = -self._scroll_offset / max_scroll
slide_range = max(self._viewport.width - indicator_w, 0.0)
x = self._viewport.x + scroll_ratio * slide_range
else:
x = self._viewport.x + (self._viewport.width - indicator_w) / 2
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)
@@ -74,23 +67,21 @@ class ScrollIndicator(Widget):
rl.Color(255, 255, 255, int(255 * 0.45)))
class Scroller(Widget):
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING,
line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING,
scroll_indicator: bool = False, edge_shadows: bool = False):
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._line_separator = LineSeparator() if line_separator else None
self._pad_start = pad_start
self._pad_end = pad_end
self._pad = pad
self._reset_scroll_at_show = True
self._scrolling_to: float | None = None
self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._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
@@ -107,22 +98,27 @@ class Scroller(Widget):
self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
self._scroll_enabled: bool | Callable[[], bool] = True
self._txt_vertical_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80)
self._show_scroll_indicator = scroll_indicator and self._horizontal
self._scroll_indicator = ScrollIndicator()
self._edge_shadows = edge_shadows and self._horizontal
for item in items:
self.add_widget(item)
# 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()
@property
def items(self) -> list[Widget]:
return self._items
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):
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
@@ -130,25 +126,35 @@ class Scroller(Widget):
# FIXME: the padding correction doesn't seem correct
scroll_offset = self.scroll_panel.get_offset() - pos
if smooth:
self._scrolling_to = scroll_offset
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 is not None
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)
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
def move_item(self, from_index: int, to_index: int) -> None:
if from_index == to_index:
return
if not (0 <= from_index < len(self._items) and 0 <= to_index < len(self._items)):
return
item = self._items.pop(from_index)
self._items.insert(to_index, 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)."""
@@ -156,7 +162,7 @@ class Scroller(Widget):
def _update_state(self):
if DO_ZOOM:
if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY:
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:
@@ -166,27 +172,25 @@ class Scroller(Widget):
else:
self._zoom_filter.update(0.85)
# Cancel auto-scroll if user starts manually scrolling
if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL):
self._scrolling_to = None
# 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 is not None:
self._scroll_filter.update(self._scrolling_to)
self.scroll_panel.set_offset(self._scroll_filter.x)
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._scroll_filter.x - self._scrolling_to) < 1:
self.scroll_panel.set_offset(self._scrolling_to)
self._scrolling_to = None
else:
# keep current scroll position up to date
self._scroll_filter.x = self.scroll_panel.get_offset()
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)
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 round(self.scroll_panel.get_offset())
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
@@ -222,29 +226,86 @@ class Scroller(Widget):
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]
# Add line separator between items
if self._line_separator is not None:
l = len(self._visible_items)
for i in range(1, len(self._visible_items)):
self._visible_items.insert(l - i, self._line_separator)
self._content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in self._visible_items)
self._content_size += self._spacing * (len(self._visible_items) - 1)
self._content_size += self._pad_start + self._pad_end
self._content_size += self._pad * 2
self._scroll_offset = self._get_scroll(self._visible_items, self._content_size)
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
self._item_pos_filter.update(self._scroll_offset)
cur_pos = 0
for idx, item in enumerate(self._visible_items):
spacing = self._spacing if (idx > 0) else self._pad_start
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
@@ -276,60 +337,125 @@ class Scroller(Widget):
[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(round(x), round(y)) # round to prevent jumping when settling
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, _):
for item in self._visible_items:
# Skip rendering if not in viewport
if not rl.check_collision_recs(item.rect, self._rect):
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)
# 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()
# 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)))
# Draw scroll indicator
if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0:
_real_content_size = self._content_size - self._rect.height + self._txt_vertical_scroll_indicator.height
scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height
scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_vertical_scroll_indicator.height)
rl.draw_texture_ex(self._txt_vertical_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)
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, 166), rl.BLANK)
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, 166))
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)
for item in self._items:
item.show_event()
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
-290
View File
@@ -1,290 +0,0 @@
from enum import IntEnum
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 Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.scroller_tici import Scroller
SELECTION_COLOR = rl.Color(70, 91, 234, 255) # #465BEA
HEADER_BG = rl.Color(51, 51, 51, 255) # #333333
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) # #1B1B1B
BORDER_COLOR = rl.Color(80, 80, 80, 255)
MARGIN = 40
OUTER_MARGIN_X = 100
OUTER_MARGIN_Y = 80
BUTTON_HEIGHT = 90
class SortMode(IntEnum):
ALPHABETICAL = 0
DATE_NEWEST = 1
DATE_OLDEST = 2
FAVORITES = 3
class SelectionHeader(Widget):
def __init__(self, text: str, is_expanded: bool, callback: Callable[[str], None]):
super().__init__()
self._text = text
self._is_expanded = is_expanded
self._callback = callback
self._font = gui_app.font(FontWeight.BOLD)
self._font_size = 40
self._pressed = False
self.set_rect(rl.Rectangle(0, 0, 0, 70))
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, rect: rl.Rectangle):
# Header background - Match Qt .series-header {#333333}
bg_color = rl.Color(64, 64, 64, 255) if self._pressed else HEADER_BG
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
# Arrow - Match Qt text-based arrows
arrow = "" if self._is_expanded else ""
arrow_pos = rl.Vector2(rect.x + 30, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, arrow, arrow_pos, self._font_size, 0, rl.WHITE)
# Text - Match Qt padding-left: 80px
text_pos = rl.Vector2(rect.x + 80, rect.y + (rect.height - self._font_size) / 2)
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
class SelectionItem(Widget):
def __init__(self, text: str, is_selected: bool, callback: Callable[[str], None]):
super().__init__()
self._text = text
self._is_selected = is_selected
self._callback = callback
self._font = gui_app.font(FontWeight.MEDIUM)
self._font_size = 48
self._pressed = False
self.set_rect(rl.Rectangle(0, 0, 0, 110))
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, rect: rl.Rectangle):
# Background for item - Match Qt .model-option:checked {#465BEA}
if self._is_selected:
bg_color = rl.Color(70, 91, 234, 255) # #465BEA
else:
bg_color = rl.Color(90, 90, 90, 255) if self._pressed else rl.Color(79, 79, 79, 255) # #4F4F4F
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
# Selection Border - Match Qt {3px WHITE}
if self._is_selected:
rl.draw_rectangle_rounded_lines_ex(rect, 0.1, 10, 3, rl.WHITE)
# Text
text_size = rl.measure_text_ex(self._font, self._text, self._font_size, 0)
text_pos = rl.Vector2(rect.x + 40, rect.y + (rect.height - text_size.y) / 2)
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
# Indicator (Dot for selection instead of radio)
if self._is_selected:
circle_center = rl.Vector2(rect.x + rect.width - 50, rect.y + rect.height / 2)
rl.draw_circle_v(circle_center, 12, rl.WHITE)
def _handle_mouse_press(self, mouse_pos):
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._pressed = True
def _handle_mouse_release(self, mouse_pos):
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
if self._callback:
self._callback(self._text)
self._pressed = False
class SelectionDialog(Widget):
def __init__(self, title: str, options, current_selection: str = "",
on_close: Callable[[DialogResult, str], None] | None = None,
model_released_dates: dict[str, str] | None = None,
model_file_to_name: dict[str, str] | None = None,
user_favorites: list[str] | None = None,
community_favorites: list[str] | None = None,
on_favorite_toggled: Callable[[str], None] | None = None,
favorites_editable: bool = True):
super().__init__()
self._title = title
self._options_raw = options
self._selected_value = current_selection
self._on_close = on_close
self._model_released_dates = model_released_dates or {}
self._name_to_file = {v: k for k, v in (model_file_to_name or {}).items()}
self._user_favorites = user_favorites or []
self._community_favorites = community_favorites or []
self._on_favorite_toggled = on_favorite_toggled
self._favorites_editable = favorites_editable
self._sort_mode = SortMode.ALPHABETICAL
self._expanded_series = {s: True for s in (options.keys() if isinstance(options, dict) else [])}
self._title_label = Label(title, 60, FontWeight.BOLD, text_color=rl.WHITE)
self._sort_button = Button("Alphabetical", self._toggle_sort, button_style=ButtonStyle.NORMAL)
self._cancel_button = Button("Cancel", self._cancel_button_callback)
self._confirm_button = Button("Select", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
self._scroller = None
self._build_scroller()
def _toggle_sort(self):
self._sort_mode = SortMode((int(self._sort_mode) + 1) % 4)
modes = ["Alphabetical", "Date (Newest)", "Date (Oldest)", "Favorites First"]
self._sort_button.set_text(modes[int(self._sort_mode)])
self._build_scroller()
def _toggle_series(self, series: str):
self._expanded_series[series] = not self._expanded_series.get(series, True)
self._build_scroller()
def _build_scroller(self):
items = []
if isinstance(self._options_raw, dict):
series_keys = list(self._options_raw.keys())
priority_series = ["StarPilot", "Comma", "Experimental"]
sorted_series_keys = []
for p in priority_series:
if p in series_keys:
sorted_series_keys.append(p)
series_keys.remove(p)
sorted_series_keys.extend(sorted(series_keys))
for series in sorted_series_keys:
models = self._options_raw[series]
if not models:
continue
items.append(SelectionHeader(series, self._expanded_series.get(series, True), self._toggle_series))
if self._expanded_series.get(series, True):
sorted_models = list(models)
if self._sort_mode == SortMode.ALPHABETICAL:
sorted_models.sort()
elif self._sort_mode == SortMode.DATE_NEWEST:
def get_date(m):
key = self._name_to_file.get(m, m)
return self._model_released_dates.get(key, "0000-00-00")
sorted_models.sort(key=get_date, reverse=True)
elif self._sort_mode == SortMode.DATE_OLDEST:
def get_date(m):
key = self._name_to_file.get(m, m)
return self._model_released_dates.get(key, "9999-99-99")
sorted_models.sort(key=get_date)
elif self._sort_mode == SortMode.FAVORITES:
def is_fav(m):
key = self._name_to_file.get(m, m)
return key in self._user_favorites or key in self._community_favorites
sorted_models.sort(key=is_fav, reverse=True)
for model in sorted_models:
key = self._name_to_file.get(model, model)
is_selected = (model == self._selected_value or key == self._selected_value)
items.append(SelectionItem(
text=model,
is_selected=is_selected,
callback=self._on_item_selected
))
else:
for option in self._options_raw:
items.append(SelectionItem(
text=option,
is_selected=(option == self._selected_value),
callback=self._on_item_selected
))
self._scroller = Scroller(items, line_separator=False, spacing=10)
self._scroller.show_event()
def _toggle_favorite(self, model_name: str):
if not self._favorites_editable:
return
key = self._name_to_file.get(model_name, model_name)
if self._on_favorite_toggled:
self._on_favorite_toggled(key)
# Update local state for instant feedback
if key in self._user_favorites:
self._user_favorites.remove(key)
else:
self._user_favorites.append(key)
self._build_scroller()
def _on_item_selected(self, val):
self._selected_value = val
# Instant visual update
if self._scroller:
for item in self._scroller._items:
if isinstance(item, SelectionItem):
item._is_selected = (item._text == val)
def _cancel_button_callback(self):
gui_app.set_modal_overlay(None)
if self._on_close:
self._on_close(DialogResult.CANCEL, "")
def _confirm_button_callback(self):
gui_app.set_modal_overlay(None)
if self._on_close:
self._on_close(DialogResult.CONFIRM, self._selected_value)
def show_event(self):
super().show_event()
if self._scroller:
self._scroller.show_event()
def _render(self, rect: rl.Rectangle):
# Dim background
rl.draw_rectangle(0, 0, int(rl.get_screen_width()), int(rl.get_screen_height()), rl.Color(0, 0, 0, 180))
# Dialog Box
dialog_rect = rl.Rectangle(
rect.x + OUTER_MARGIN_X,
rect.y + OUTER_MARGIN_Y,
rect.width - 2 * OUTER_MARGIN_X,
rect.height - 2 * OUTER_MARGIN_Y,
)
rl.draw_rectangle_rounded(dialog_rect, 0.04, 12, BACKGROUND_COLOR)
rl.draw_rectangle_rounded_lines_ex(dialog_rect, 0.04, 12, 2, BORDER_COLOR)
# Title
title_width = dialog_rect.width - 2 * MARGIN - 260
self._title_label.render(rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, title_width, 80))
# Sort Button
self._sort_button.render(rl.Rectangle(dialog_rect.x + dialog_rect.width - MARGIN - 240, dialog_rect.y + MARGIN, 240, 80))
# Bottom Buttons
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
self._cancel_button.render(rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT))
self._confirm_button.render(rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT))
# Scrollable Options List
scroller_y = dialog_rect.y + MARGIN + 80 + 20
scroller_rect = rl.Rectangle(
dialog_rect.x + MARGIN,
scroller_y,
dialog_rect.width - 2 * MARGIN,
btn_y - scroller_y - 20
)
self._scroller.render(scroller_rect)
return DialogResult.NO_ACTION
+30 -36
View File
@@ -1,3 +1,4 @@
import abc
from collections.abc import Callable
import pyray as rl
@@ -5,22 +6,24 @@ 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 BounceFilter, FirstOrderFilter
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
class SmallSlider(Widget):
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):
# TODO: unify this with BigConfirmationDialogV2
super().__init__()
self._confirm_callback = confirm_callback
self._shimmer_offset = shimmer_offset
self._font = gui_app.font(FontWeight.DISPLAY)
self._load_assets()
self._drag_threshold = -self._rect.width // 2
@@ -37,17 +40,13 @@ class SmallSlider(Widget):
self._is_dragging_circle = False
self._label = 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)
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):
self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100)
self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100)
self._circle_bg_pressed_txt = self._circle_bg_txt
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
...
@property
def confirmed(self) -> bool:
@@ -57,15 +56,13 @@ class SmallSlider(Widget):
super().show_event()
self.reset()
def reset(self, reset_shimmer: bool = True):
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._circle_press_time = None
self._circle_scale_filter.x = 1.0
if reset_shimmer:
self._label.reset_shimmer(self._shimmer_offset)
self._label.reset_shimmer(self._shimmer_offset)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
@@ -114,15 +111,15 @@ class SmallSlider(Widget):
activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
if self._confirmed_time > 0:
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._on_confirm()
self._confirm_callback_called = True
self._on_confirm()
elif not self._is_dragging_circle:
# reset back to right
@@ -132,8 +129,6 @@ class SmallSlider(Widget):
self._scroll_x_circle_filter.x = self._scroll_x_circle
def _render(self, _):
# TODO: iOS text shimmering animation
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
@@ -154,21 +149,20 @@ class SmallSlider(Widget):
)
self._label.render(label_rect)
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 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 = scaled_btn_x + (self._circle_bg_txt.width * scale - self._circle_arrow_txt.width) / 2
arrow_y = scaled_btn_y + (self._circle_bg_txt.height * scale - self._circle_arrow_txt.height) / 2
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(SmallSlider):
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)
@@ -179,24 +173,24 @@ class LargerSlider(SmallSlider):
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 = self._circle_bg_txt
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(SmallSlider):
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 = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
line_height=0.875, shimmer=True)
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_hover.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
@@ -206,5 +200,5 @@ class RedBigSlider(BigSlider):
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_hover.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