Reduce onroad UI work and move parameter refresh off render thread

This commit is contained in:
firestar5683
2026-09-22 08:49:27 -05:00
parent 7f0c5673b4
commit cdc6b3bd68
15 changed files with 1010 additions and 159 deletions
+102 -10
View File
@@ -2,18 +2,29 @@
Parameter reads are file-backed. The raylib UIs ask for the same values from
multiple widgets during a frame, so a short cache avoids repeated open/read/
close cycles without making settings changes sticky: every write invalidates
the affected key immediately and the short TTL bounds visibility of writes
from other processes.
close cycles. Writes invalidate immediately; opt-in background refresh keeps
expired reads off the render thread while retaining the latest cached value.
"""
from __future__ import annotations
import os
import queue
import threading
import time
from collections.abc import Callable
from typing import Any
from typing import Any, NamedTuple
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import PC
class _RefreshRequest(NamedTuple):
cache_key: tuple[Any, ...]
cached: tuple[float, Any]
args: tuple[Any, ...]
kwargs: dict[str, Any]
class UIParamCache:
@@ -23,9 +34,83 @@ class UIParamCache:
self._ttl = max(0.0, ttl)
self._clock = clock
self._cache: dict[tuple[Any, ...], tuple[float, Any]] = {}
self._lock = threading.Lock()
self._worker: threading.Thread | None = None
self._refresh_queue: queue.Queue[_RefreshRequest | None] = queue.Queue()
self._pending: set[tuple[Any, ...]] = set()
self._stop_event = threading.Event()
def start(self) -> None:
with self._lock:
if self._worker is not None:
return
self._refresh_queue = queue.Queue()
self._pending = set()
self._stop_event = threading.Event()
self._worker = threading.Thread(target=self._refresh_worker, name="ui-param-cache", daemon=True,
args=(self._refresh_queue, self._pending, self._stop_event))
self._worker.start()
def stop(self, timeout: float = 1.0) -> None:
with self._lock:
worker = self._worker
if worker is None:
return
self._worker = None
self._stop_event.set()
self._refresh_queue.put(None)
worker.join(timeout)
def _refresh_worker(self, requests: queue.Queue[_RefreshRequest | None], pending: set[tuple[Any, ...]],
stop_event: threading.Event) -> None:
if not PC:
try:
os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0))
except OSError:
cloudlog.exception("Unable to lower UI parameter worker priority")
with self._lock:
if self._stop_event is stop_event:
self._worker = None
return
while True:
request = requests.get()
try:
if request is None:
return
with self._lock:
current = not stop_event.is_set() and self._cache.get(request.cache_key) is request.cached
if not current:
continue
method, key = request.cache_key[:2]
try:
value = getattr(self._params, method)(key, *request.args, **request.kwargs)
except Exception:
value = request.cached[1]
refreshed_at = self._clock()
with self._lock:
if not stop_event.is_set() and self._cache.get(request.cache_key) is request.cached:
self._cache[request.cache_key] = (refreshed_at, value)
finally:
if request is not None:
with self._lock:
pending.discard(request.cache_key)
requests.task_done()
def _queue_refresh(self, cache_key: tuple[Any, ...], cached: tuple[float, Any],
args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
if cache_key in self._pending:
return
with self._lock:
if self._worker is not None and cache_key not in self._pending and self._cache.get(cache_key) is cached:
self._pending.add(cache_key)
self._refresh_queue.put(_RefreshRequest(cache_key, cached, args, kwargs))
@staticmethod
def _cache_key(method: str, key: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any, ...]:
if not args and not kwargs:
return method, key
# Params arguments are primitive values in UI call sites. repr keeps this
# robust for an occasional list/dict default without requiring hashability.
return (method, key, repr(args), repr(sorted(kwargs.items())))
@@ -37,8 +122,14 @@ class UIParamCache:
if cached is not None and now - cached[0] < self._ttl:
return cached[1]
blocking = kwargs.get("block", args[0] if args else False)
if cached is not None and self._worker is not None and self._ttl > 0 and not blocking:
self._queue_refresh(cache_key, cached, args, kwargs)
return cached[1]
value = getattr(self._params, method)(key, *args, **kwargs)
self._cache[cache_key] = (now, value)
with self._lock:
self._cache[cache_key] = (now, value)
return value
def get(self, key: str, *args: Any, **kwargs: Any) -> Any:
@@ -54,11 +145,12 @@ class UIParamCache:
return self._read("get_float", key, *args, **kwargs)
def invalidate(self, key: str | None = None) -> None:
if key is None:
self._cache.clear()
return
self._cache = {cache_key: value for cache_key, value in self._cache.items()
if cache_key[1] != key}
with self._lock:
if key is None:
self._cache.clear()
return
self._cache = {cache_key: value for cache_key, value in self._cache.items()
if cache_key[1] != key}
def put(self, key: str, value: Any, *args: Any, **kwargs: Any) -> None:
self._params.put(key, value, *args, **kwargs)
+18 -51
View File
@@ -108,8 +108,10 @@ class ModelRenderer(Widget):
self._longitudinal_control = cp.openpilotLongitudinalControl
def set_transform(self, transform: np.ndarray):
self._car_space_transform = transform.astype(np.float32)
self._transform_dirty = True
transform = transform.astype(np.float32)
if not np.array_equal(transform, self._car_space_transform):
self._car_space_transform = transform
self._transform_dirty = True
def _render(self, rect: rl.Rectangle):
sm = ui_state.sm
@@ -578,66 +580,31 @@ class ModelRenderer(Widget):
if line.shape[0] == 0:
return np.empty((0, 2), dtype=np.float32)
# Slice points and filter non-negative x-coordinates
points = line[:max_idx + 1]
points = points[points[:, 0] >= 0]
if points.shape[0] == 0:
n = len(points)
if n == 0:
return np.empty((0, 2), dtype=np.float32)
N = points.shape[0]
# Generate left and right 3D points in one array using broadcasting
offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
points_3d = points[None, :, :] + offsets[:, None, :]
proj = (self._car_space_transform @ points_3d.reshape(2 * n, 3).T).reshape(3, 2, n)
# Transform all points to projected space in one operation
proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
proj = proj.reshape(3, 2, N)
left_proj = proj[:, 0, :]
right_proj = proj[:, 1, :]
valid = (points[:, 0] >= 0) & (np.abs(proj[2, 0]) >= 1e-6) & (np.abs(proj[2, 1]) >= 1e-6)
np.divide(proj[:2], proj[2], out=proj[:2], where=valid)
# Filter points where z is sufficiently large
valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
if not np.any(valid_proj):
return np.empty((0, 2), dtype=np.float32)
# Compute screen coordinates
left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
# Define clip region bounds
clip = self._clip_region
x_min, x_max = clip.x, clip.x + clip.width
y_min, y_max = clip.y, clip.y + clip.height
for side in (0, 1):
valid &= ((proj[0, side] >= x_min) & (proj[0, side] <= x_max) &
(proj[1, side] >= y_min) & (proj[1, side] <= y_max))
# Filter points within clip region
left_in_clip = (
(left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
(left_screen[1] >= y_min) & (left_screen[1] <= y_max)
)
right_in_clip = (
(right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
(right_screen[1] >= y_min) & (right_screen[1] <= y_max)
)
both_in_clip = left_in_clip & right_in_clip
screen = proj[:2, :, valid]
if not allow_invert:
keep = screen[1, 0] == np.minimum.accumulate(screen[1, 0])
screen = screen[:, :, keep]
if not np.any(both_in_clip):
return np.empty((0, 2), dtype=np.float32)
# Select valid and clipped points
left_screen = left_screen[:, both_in_clip]
right_screen = right_screen[:, both_in_clip]
# Handle Y-coordinate inversion on hills
if not allow_invert and left_screen.shape[1] > 1:
y = left_screen[1, :] # y-coordinates
keep = y == np.minimum.accumulate(y)
if not np.any(keep):
return np.empty((0, 2), dtype=np.float32)
left_screen = left_screen[:, keep]
right_screen = right_screen[:, keep]
return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
return np.concatenate((screen[:, 0].T, screen[:, 1, ::-1].T)).astype(np.float32, copy=False)
@staticmethod
def _hsla_to_color(h, s, l, a):
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import math
import time
from pathlib import Path
import pyray as rl
@@ -15,6 +16,7 @@ from openpilot.system.ui.widgets import Widget
ASSETS_PATH = Path(__file__).resolve().parents[4] / "starpilot" / "assets" / "navigation"
FALLBACK_ICON = "direction_turn_straight.png"
NAV_CANCEL_HOLD_SECONDS = 0.65
NAV_STATE_UPDATE_INTERVAL = 0.1
def _format_distance(distance_m: float, is_metric: bool) -> str:
@@ -62,6 +64,7 @@ class NavigationCardRenderer(Widget):
self._font_bold = gui_app.font(FontWeight.BOLD)
self._font_medium = gui_app.font(FontWeight.MEDIUM)
self._icons: dict[str, rl.Texture2D] = {}
self._icon_filenames: dict[tuple[str, str], str] = {}
self._layout_variant = layout_variant
self._enabled = False
@@ -80,6 +83,8 @@ class NavigationCardRenderer(Widget):
self._interactive_rect = rl.Rectangle(0, 0, 0, 0)
self._click_delay = 0.15
self._press_started_at: float | None = None
self._last_state_update = -math.inf
self._state_context: tuple[object, bool] | None = None
@property
def _hit_rect(self) -> rl.Rectangle:
@@ -97,7 +102,8 @@ class NavigationCardRenderer(Widget):
except UnknownKeyName:
self._collapsed_param_supported = False
self._collapsed_fallback = new_state
self._collapsed = new_state
self._collapsed = new_state
self._last_state_update = -math.inf
def _cancel_navigation(self) -> None:
params = ui_state.ui_params
@@ -120,6 +126,9 @@ class NavigationCardRenderer(Widget):
self._collapsed_fallback = False
self._collapsed = False
self._valid = False
self._interactive_rect = rl.Rectangle(0, 0, 0, 0)
self._last_state_update = -math.inf
self._state_context = None
def _handle_mouse_press(self, mouse_pos) -> None:
self._press_started_at = rl.get_time()
@@ -140,20 +149,24 @@ class NavigationCardRenderer(Widget):
self._toggle_collapsed()
def _icon_filename(self, maneuver_type: str, modifier: str) -> str:
key = (maneuver_type, modifier)
if key in self._icon_filenames:
return self._icon_filenames[key]
normalized_type = _normalize_maneuver_type(maneuver_type)
if modifier == "uturn":
return "direction_uturn.png"
candidate = "direction_uturn.png"
else:
suffix = _modifier_suffix(modifier)
candidate = f"direction_{normalized_type}.png" if suffix == "" else f"direction_{normalized_type}_{suffix}.png"
suffix = _modifier_suffix(modifier)
candidate = f"direction_{normalized_type}.png" if suffix == "" else f"direction_{normalized_type}_{suffix}.png"
return candidate if (ASSETS_PATH / candidate).exists() else FALLBACK_ICON
self._icon_filenames[key] = candidate if (ASSETS_PATH / candidate).exists() else FALLBACK_ICON
return self._icon_filenames[key]
def _get_icon(self, maneuver_type: str, modifier: str):
icon_name = self._icon_filename(maneuver_type, modifier)
if icon_name not in self._icons:
icon_path = ASSETS_PATH / icon_name
if not icon_path.exists():
icon_path = ASSETS_PATH / FALLBACK_ICON
image = rl.load_image(str(icon_path))
texture = rl.load_texture_from_image(image)
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
@@ -165,15 +178,23 @@ class NavigationCardRenderer(Widget):
def _update_state(self) -> None:
params = ui_state.ui_params
self._enabled = params.get_bool("NavigationUI")
destination = params.get("NavDestination") if self._enabled else None
if not destination:
self._valid = False
self._interactive_rect = rl.Rectangle(0, 0, 0, 0)
self._state_context = None
return
now = time.monotonic()
context = (destination, ui_state.is_metric)
if context == self._state_context and now - self._last_state_update < NAV_STATE_UPDATE_INTERVAL:
return
self._last_state_update = now
self._state_context = context
self._valid = False
self._interactive_rect = rl.Rectangle(0, 0, 0, 0)
if not self._enabled:
return
if not (params.get("NavDestination") or ""):
return
raw_state = ui_state.params_memory.get("NavInstructionState") or {}
if isinstance(raw_state, str):
try:
@@ -513,7 +534,6 @@ class NavigationCardRenderer(Widget):
)
def _render(self, rect: rl.Rectangle) -> None:
self._update_state()
if not self._valid:
return
if self._collapsed:
@@ -0,0 +1,103 @@
from types import SimpleNamespace
import numpy as np
import pytest
from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
@pytest.fixture
def renderer():
renderer = object.__new__(ModelRenderer)
renderer._car_space_transform = np.array([[580, -480, 0], [400, 0, -480], [1, 0, 0]], dtype=np.float32)
renderer._transform_dirty = False
renderer._clip_region = SimpleNamespace(x=-500, y=-500, width=2160, height=1800)
return renderer
def reference_polygon(renderer, line, y_off, z_off, max_idx, allow_invert):
left, right = [], []
clip = renderer._clip_region
previous_y = float('inf')
for point in line[:max_idx + 1]:
if not point[0] >= 0:
continue
edges = [renderer._car_space_transform @ (point + np.array([0, offset, z_off], dtype=np.float32))
for offset in (-y_off, y_off)]
if not all(abs(edge[2]) >= 1e-6 for edge in edges):
continue
edges = [edge[:2] / edge[2] for edge in edges]
if not all(clip.x <= edge[0] <= clip.x + clip.width and clip.y <= edge[1] <= clip.y + clip.height for edge in edges):
continue
if not allow_invert and edges[0][1] > previous_y:
continue
previous_y = edges[0][1]
left.append(edges[0])
right.append(edges[1])
return np.array(left + right[::-1], dtype=np.float32).reshape(-1, 2)
@pytest.mark.parametrize('allow_invert', [False, True])
@pytest.mark.parametrize('length', [0, 1, 33, 100])
@pytest.mark.parametrize('max_idx', [0, 15, 100])
@pytest.mark.parametrize('dtype', [np.float32, np.float64])
def test_projection_preserves_clipping_and_hill_geometry(renderer, allow_invert, length, max_idx, dtype):
rng = np.random.default_rng(42)
line = np.column_stack((np.linspace(-5, 110, length), rng.normal(0, 6, length), rng.normal(0, 1, length))).astype(dtype)
expected = reference_polygon(renderer, line, 0.9, 1.22, max_idx, allow_invert)
actual = renderer._map_line_to_polygon(line, 0.9, 1.22, max_idx, allow_invert)
assert actual.dtype == np.float32
assert actual.flags.c_contiguous
np.testing.assert_allclose(actual, expected, rtol=1e-5, atol=1e-3)
@pytest.mark.parametrize('allow_invert', [False, True])
def test_projection_discards_zero_depth_and_out_of_view_points(renderer, allow_invert):
line = np.array([[0, 0, 0], [1e-7, 0, 0], [-5, 0, 0], [1, 100, 0]], dtype=np.float32)
with np.errstate(divide='raise', invalid='raise'):
actual = renderer._map_line_to_polygon(line, 0.9, 1.22, len(line), allow_invert)
assert actual.shape == (0, 2)
def test_projection_requires_both_polygon_edges_inside_clip(renderer):
renderer._clip_region = SimpleNamespace(x=0, y=0, width=100, height=100)
renderer._car_space_transform = np.eye(3, dtype=np.float32)
line = np.array([[10, 10, 1], [20, 99, 1], [30, 15, 1]], dtype=np.float32)
actual = renderer._map_line_to_polygon(line, 2, 0, len(line))
np.testing.assert_array_equal(actual, [[10, 8], [30, 13], [30, 17], [10, 12]])
def test_projection_removes_hill_inversions_after_clipping(renderer):
renderer._car_space_transform = np.eye(3, dtype=np.float32)
line = np.array([[10, 50, 1], [20, 40, 1], [30, 45, 1], [40, 30, 1], [50, 30, 1]], dtype=np.float32)
actual = renderer._map_line_to_polygon(line, 2, 0, len(line), allow_invert=False)
np.testing.assert_array_equal(actual[:len(actual) // 2], [[10, 48], [20, 38], [40, 28], [50, 28]])
def test_unchanged_transform_keeps_geometry_cached(renderer):
original = renderer._car_space_transform
renderer.set_transform(original.astype(np.float64))
assert renderer._car_space_transform is original
assert not renderer._transform_dirty
renderer._transform_dirty = True
renderer.set_transform(original)
assert renderer._transform_dirty
def test_changed_transform_invalidates_geometry_and_copies_input(renderer):
transform = renderer._car_space_transform.astype(np.float64)
transform[0, 0] += 1
renderer.set_transform(transform)
assert renderer._transform_dirty
assert renderer._car_space_transform.dtype == np.float32
np.testing.assert_array_equal(renderer._car_space_transform, transform)
transform[0, 0] += 1
assert renderer._car_space_transform[0, 0] != transform[0, 0]
+176
View File
@@ -0,0 +1,176 @@
import json
from collections import Counter
from types import SimpleNamespace
import pyray as rl
import pytest
from openpilot.common.params import UnknownKeyName
from openpilot.selfdrive.ui.onroad.starpilot import navigation_card
class FakeParams:
def __init__(self, values):
self.values = values
self.reads = Counter()
def get(self, key):
self.reads[key] += 1
return self.values.get(key)
def get_bool(self, key):
return bool(self.get(key))
def put_bool(self, key, value):
self.values[key] = value
def remove(self, key):
self.values.pop(key, None)
@pytest.fixture
def card_state(monkeypatch):
params = FakeParams({"NavigationUI": True, "NavDestination": {"name": "Home"}})
nav_state = {"valid": True, "maneuverPrimaryText": "Main Street", "maneuverDistance": 1500}
memory = FakeParams({"NavInstructionState": json.dumps(nav_state), "NavInstructionCollapsed": False})
ui = SimpleNamespace(ui_params=params, params_memory=memory, is_metric=True)
clock = [0.0]
monkeypatch.setattr(navigation_card, "ui_state", ui)
monkeypatch.setattr(navigation_card.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(navigation_card.gui_app, "font", lambda _: None)
card = navigation_card.NavigationCardRenderer()
return card, ui, clock
def test_navigation_state_reads_are_bounded_at_sixty_fps(card_state):
card, ui, clock = card_state
for frame in range(60):
clock[0] = frame / 60
card._update_state()
assert card._valid
assert 8 <= ui.params_memory.reads["NavInstructionState"] <= 10
assert ui.params_memory.reads["NavInstructionCollapsed"] == ui.params_memory.reads["NavInstructionState"]
def test_render_does_not_repeat_state_update(card_state, monkeypatch):
card, ui, _ = card_state
card._update_state()
reads = ui.ui_params.reads.copy()
monkeypatch.setattr(card, "_render_default", lambda _: None)
card._render(rl.Rectangle(0, 0, 100, 100))
assert ui.ui_params.reads == reads
def test_metric_and_destination_changes_bypass_refresh_interval(card_state):
card, ui, clock = card_state
card._update_state()
assert card._distance == "1.5 km"
clock[0] = 0.01
ui.is_metric = False
card._update_state()
assert card._distance == "0.9 mi"
ui.ui_params.values["NavDestination"] = {"name": "Work"}
ui.params_memory.values["NavInstructionState"] = {"valid": True, "maneuverPrimaryText": "Second Street"}
card._update_state()
assert card._primary_text == "Second Street"
assert ui.params_memory.reads["NavInstructionState"] == 3
@pytest.mark.parametrize("key, value", [("NavigationUI", False), ("NavDestination", None)])
def test_disabled_navigation_clears_card_and_reenables_immediately(card_state, key, value):
card, ui, clock = card_state
card._update_state()
card._interactive_rect = rl.Rectangle(0, 0, 100, 100)
previous = ui.ui_params.values[key]
ui.ui_params.values[key] = value
clock[0] = 0.01
card._update_state()
assert not card._valid
assert card._hit_rect.width == 0
assert ui.params_memory.reads["NavInstructionState"] == 1
ui.ui_params.values[key] = previous
card._update_state()
assert card._valid
assert ui.params_memory.reads["NavInstructionState"] == 2
@pytest.mark.parametrize("state", [None, "{invalid json", {"valid": False}, {"valid": True, "maneuverPrimaryText": ""}])
def test_invalid_state_clears_card_at_next_refresh(card_state, state):
card, ui, clock = card_state
card._update_state()
card._interactive_rect = rl.Rectangle(0, 0, 100, 100)
ui.params_memory.values["NavInstructionState"] = state
clock[0] = 0.1
card._update_state()
assert not card._valid
assert card._hit_rect.width == 0
@pytest.mark.parametrize("supported", [False, True])
def test_collapse_changes_immediately_and_forces_refresh(card_state, monkeypatch, supported):
card, ui, _ = card_state
card._update_state()
if not supported:
def unsupported(*_):
raise UnknownKeyName("NavInstructionCollapsed")
monkeypatch.setattr(ui.params_memory, "put_bool", unsupported)
card._toggle_collapsed()
assert card._collapsed
card._update_state()
assert card._collapsed
assert ui.params_memory.reads["NavInstructionState"] == 2
card._toggle_collapsed()
assert not card._collapsed
def test_cancel_navigation_clears_card_immediately(card_state):
card, ui, _ = card_state
card._update_state()
card._interactive_rect = rl.Rectangle(0, 0, 100, 100)
card._cancel_navigation()
assert not card._valid
assert card._hit_rect.width == 0
assert "NavDestination" not in ui.ui_params.values
assert "NavInstructionState" not in ui.params_memory.values
assert "NavInstructionCollapsed" not in ui.params_memory.values
card._update_state()
assert not card._valid
@pytest.mark.parametrize("maneuver, modifier, expected", [
("turn", "left", "direction_turn_left.png"),
("turn", "uturn", "direction_uturn.png"),
("missing", "right", navigation_card.FALLBACK_ICON),
])
def test_icon_resolution_and_texture_load_are_cached(card_state, monkeypatch, maneuver, modifier, expected):
card, _, _ = card_state
checks = []
loads = []
texture = object()
def exists(path):
checks.append(path.name)
return not path.name.startswith("direction_missing")
monkeypatch.setattr(navigation_card.Path, "exists", exists)
monkeypatch.setattr(rl, "load_image", lambda path: loads.append(path))
monkeypatch.setattr(rl, "load_texture_from_image", lambda _: texture)
monkeypatch.setattr(rl, "set_texture_filter", lambda *_: None)
monkeypatch.setattr(rl, "set_texture_wrap", lambda *_: None)
monkeypatch.setattr(rl, "unload_image", lambda _: None)
for _ in range(60):
assert card._get_icon(maneuver, modifier) is texture
assert len(checks) == 1
assert loads == [str(navigation_card.ASSETS_PATH / expected)]
+269 -65
View File
@@ -1,5 +1,8 @@
import unittest
import threading
import pytest
from openpilot.selfdrive.ui.lib import ui_param_cache
from openpilot.selfdrive.ui.lib.ui_param_cache import UIParamCache
@@ -50,72 +53,273 @@ class FakeParams:
self.values.clear()
class TestUIParamCache(unittest.TestCase):
def test_reads_are_shared_until_ttl(self):
now = [0.0]
params = FakeParams()
cached = UIParamCache(params, ttl=0.1, clock=lambda: now[0])
def test_reads_are_shared_until_ttl():
now = [0.0]
params = FakeParams()
cached = UIParamCache(params, ttl=0.1, clock=lambda: now[0])
self.assertFalse(cached.get_bool("enabled"))
self.assertFalse(cached.get_bool("enabled"))
self.assertEqual(params.calls, [("get_bool", "enabled")])
assert not cached.get_bool("enabled")
assert not cached.get_bool("enabled")
assert params.calls == [("get_bool", "enabled")]
now[0] = 0.11
now[0] = 0.11
params.values["enabled"] = True
assert cached.get_bool("enabled")
assert params.calls.count(("get_bool", "enabled")) == 2
def test_writes_invalidate_immediately():
params = FakeParams()
cached = UIParamCache(params, ttl=10.0)
assert not cached.get_bool("enabled")
cached.put_bool("enabled", True)
assert cached.get_bool("enabled")
assert params.calls.count(("get_bool", "enabled")) == 2
@pytest.mark.parametrize("background", [False, True])
def test_zero_ttl_disables_caching(background):
params = FakeParams()
cached = UIParamCache(params, ttl=0.0)
if background:
cached.start()
try:
assert not cached.get_bool("enabled")
params.values["enabled"] = True
self.assertTrue(cached.get_bool("enabled"))
self.assertEqual(params.calls.count(("get_bool", "enabled")), 2)
def test_writes_invalidate_immediately(self):
params = FakeParams()
cached = UIParamCache(params, ttl=10.0)
self.assertFalse(cached.get_bool("enabled"))
cached.put_bool("enabled", True)
self.assertTrue(cached.get_bool("enabled"))
self.assertEqual(params.calls.count(("get_bool", "enabled")), 2)
def test_zero_ttl_disables_caching(self):
params = FakeParams()
cached = UIParamCache(params, ttl=0.0)
self.assertFalse(cached.get_bool("enabled"))
params.values["enabled"] = True
self.assertTrue(cached.get_bool("enabled"))
self.assertEqual(params.calls.count(("get_bool", "enabled")), 2)
def test_all_write_paths_invalidate(self):
operations = (
lambda cache: cache.put("enabled", True),
lambda cache: cache.put_bool("enabled", True),
lambda cache: cache.put_int("enabled", 1),
lambda cache: cache.put_float("enabled", 1.0),
lambda cache: cache.put_nonblocking("enabled", True),
lambda cache: cache.put_bool_nonblocking("enabled", True),
)
for operation_index, operation in enumerate(operations):
with self.subTest(operation_index=operation_index):
params = FakeParams()
cached = UIParamCache(params, ttl=10.0)
self.assertFalse(cached.get_bool("enabled"))
operation(cached)
self.assertTrue(cached.get_bool("enabled"))
self.assertEqual(params.calls.count(("get_bool", "enabled")), 2)
def test_remove_and_clear_all_invalidate(self):
params = FakeParams()
cached = UIParamCache(params, ttl=10.0)
self.assertTrue(cached.get_int("count"))
cached.remove("count")
self.assertEqual(cached.get_int("count"), 0)
self.assertFalse(cached.get_bool("enabled"))
params.values["enabled"] = True
cached.clear_all("flag")
params.values["enabled"] = True
self.assertTrue(cached.get_bool("enabled"))
self.assertIn(("clear_all", "flag"), params.calls)
assert cached.get_bool("enabled")
assert params.calls.count(("get_bool", "enabled")) == 2
finally:
cached.stop()
if __name__ == "__main__":
unittest.main()
@pytest.mark.parametrize("method", ["put", "put_bool", "put_int", "put_float", "put_nonblocking", "put_bool_nonblocking"])
def test_all_write_paths_invalidate(method):
params = FakeParams()
cached = UIParamCache(params, ttl=10.0)
assert not cached.get_bool("enabled")
getattr(cached, method)("enabled", True)
assert cached.get_bool("enabled")
assert params.calls.count(("get_bool", "enabled")) == 2
def test_remove_and_clear_all_invalidate():
params = FakeParams()
cached = UIParamCache(params, ttl=10.0)
assert cached.get_int("count") == 1
cached.remove("count")
assert cached.get_int("count") == 0
assert not cached.get_bool("enabled")
params.values["enabled"] = True
cached.clear_all("flag")
params.values["enabled"] = True
assert cached.get_bool("enabled")
assert ("clear_all", "flag") in params.calls
class ControlledParams(FakeParams):
def __init__(self):
super().__init__()
self.block_next = False
self.fail_next = False
self.read_started = threading.Event()
self.release_read = threading.Event()
self.reader_threads = []
def get_int(self, key, *args, **kwargs):
self.reader_threads.append(threading.current_thread())
value = super().get_int(key, **kwargs)
if self.block_next:
self.block_next = False
self.read_started.set()
if not self.release_read.wait(3):
raise TimeoutError("Blocked parameter test read was not released")
if self.fail_next:
self.fail_next = False
raise OSError("Parameter temporarily unavailable")
return value
@pytest.fixture
def async_cache():
params = ControlledParams()
now = [0.0]
cache = UIParamCache(params, ttl=0.1, clock=lambda: now[0])
cache.start()
try:
yield params, cache, now
finally:
params.release_read.set()
cache.stop()
def test_background_cache_keeps_cold_reads_synchronous(async_cache):
params, cache, _ = async_cache
assert cache.get_int("count") == 1
assert params.reader_threads == [threading.current_thread()]
def test_stale_read_returns_while_refresh_is_blocked_and_deduplicates(async_cache):
params, cache, now = async_cache
assert cache.get_int("count") == 1
params.values["count"] = 2
params.block_next = True
now[0] = 0.2
assert cache.get_int("count") == 1
assert params.read_started.wait(1)
for _ in range(100):
assert cache.get_int("count") == 1
assert len(params.calls) == 2
assert len(cache._pending) == 1
now[0] = 0.5
params.release_read.set()
cache._refresh_queue.join()
assert cache.get_int("count") == 2
assert len(params.calls) == 2
assert not cache._pending
@pytest.mark.parametrize("clear_all", [False, True])
def test_invalidating_an_inflight_refresh_preserves_new_reads(async_cache, clear_all):
params, cache, now = async_cache
assert cache.get_int("count") == 1
params.block_next = True
now[0] = 0.2
assert cache.get_int("count") == 1
assert params.read_started.wait(1)
if clear_all:
cache.clear_all()
params.values["count"] = 3
else:
cache.put_int("count", 3)
assert cache.get_int("count") == 3
params.release_read.set()
cache._refresh_queue.join()
assert cache.get_int("count") == 3
def test_background_errors_keep_value_and_allow_later_refresh(async_cache):
params, cache, now = async_cache
assert cache.get_int("count") == 1
params.fail_next = True
params.values["count"] = 2
now[0] = 0.2
assert cache.get_int("count") == 1
cache._refresh_queue.join()
assert cache.get_int("count") == 1
assert cache._worker.is_alive()
assert len(params.calls) == 2
now[0] = 0.4
assert cache.get_int("count") == 1
cache._refresh_queue.join()
assert cache.get_int("count") == 2
def test_blocking_reads_remain_synchronous(async_cache):
params, cache, now = async_cache
assert cache.get_int("count", block=True) == 1
params.values["count"] = 2
now[0] = 0.2
assert cache.get_int("count", block=True) == 2
assert params.reader_threads == [threading.current_thread()] * 2
assert not cache._pending
def test_background_cache_does_not_refresh_unused_settings(async_cache):
params, cache, now = async_cache
assert cache.get_bool("enabled") is False
assert cache.get_int("count") == 1
now[0] = 0.2
cache.get_int("count")
cache._refresh_queue.join()
assert params.calls.count(("get_bool", "enabled")) == 1
assert params.calls.count(("get_int", "count")) == 2
def test_stop_releases_worker_and_restores_synchronous_refresh(async_cache):
params, cache, now = async_cache
assert cache.get_int("count") == 1
worker = cache._worker
cache.start()
assert cache._worker is worker
cache.stop()
assert not worker.is_alive()
params.values["count"] = 2
now[0] = 0.2
assert cache.get_int("count") == 2
assert params.reader_threads == [threading.current_thread()] * 2
def test_timed_out_worker_cannot_publish_after_restart(async_cache):
params, cache, now = async_cache
assert cache.get_int("count") == 1
params.values["count"] = 2
params.block_next = True
now[0] = 0.2
assert cache.get_int("count") == 1
assert params.read_started.wait(1)
old_worker, old_queue = cache._worker, cache._refresh_queue
cache.stop(timeout=0.01)
assert old_worker.is_alive()
params.values["count"] = 3
cache.start()
assert cache.get_int("count") == 1
cache._refresh_queue.join()
assert cache.get_int("count") == 3
params.release_read.set()
old_queue.join()
old_worker.join(1)
assert not old_worker.is_alive()
assert cache.get_int("count") == 3
def test_worker_lowers_its_own_realtime_priority(monkeypatch):
calls = []
configured = threading.Event()
def set_scheduler(pid, policy, priority):
calls.append((pid, policy, priority, threading.current_thread()))
configured.set()
monkeypatch.setattr(ui_param_cache, "PC", False)
monkeypatch.setattr(ui_param_cache.os, "SCHED_OTHER", 0, raising=False)
monkeypatch.setattr(ui_param_cache.os, "sched_param", lambda priority: priority, raising=False)
monkeypatch.setattr(ui_param_cache.os, "sched_setscheduler", set_scheduler, raising=False)
cache = UIParamCache(FakeParams())
cache.start()
try:
assert configured.wait(1)
assert calls == [(0, 0, 0, cache._worker)]
finally:
cache.stop()
def test_worker_priority_failure_restores_synchronous_reads(monkeypatch):
def set_scheduler(*_):
raise PermissionError("Unable to change scheduler")
monkeypatch.setattr(ui_param_cache, "PC", False)
monkeypatch.setattr(ui_param_cache.os, "SCHED_OTHER", 0, raising=False)
monkeypatch.setattr(ui_param_cache.os, "sched_param", lambda priority: priority, raising=False)
monkeypatch.setattr(ui_param_cache.os, "sched_setscheduler", set_scheduler, raising=False)
monkeypatch.setattr(ui_param_cache.cloudlog, "exception", lambda *_: None)
params = FakeParams()
now = [0.0]
cache = UIParamCache(params, clock=lambda: now[0])
assert cache.get_int("count") == 1
cache.start()
worker = cache._worker
if worker is not None:
worker.join(1)
assert not worker.is_alive()
params.values["count"] = 2
now[0] = 0.2
assert cache.get_int("count") == 2
assert cache._worker is None
+4 -1
View File
@@ -23,6 +23,7 @@ def _stall_context() -> dict[str, object]:
"ui_state_frame": ui_state.sm.frame,
"target_fps": gui_app.target_fps,
"active_widget": type(active_widget).__name__ if active_widget is not None else "none",
"frame_timing": gui_app.frame_timing._asdict(),
}
try:
@@ -44,13 +45,14 @@ def _stall_context() -> dict[str, object]:
def main():
cores = {5, }
config_realtime_process(0, Priority.UI)
config_realtime_process(0, Priority.CTRL_HIGH)
stall_monitor = UIStallMonitor("raylib_ui")
stall_monitor.progress("ui.before_init_window")
stall_monitor.start()
try:
ui_state.ui_params.start()
gui_app.init_window("UI")
stall_monitor.progress("ui.after_init_window")
gui_app.set_progress_hook(stall_monitor.progress)
@@ -88,6 +90,7 @@ def main():
stall_monitor.progress("ui.loop_idle")
finally:
gui_app.set_progress_hook(None)
ui_state.ui_params.stop()
stall_monitor.stop()
+32 -4
View File
@@ -65,7 +65,7 @@ OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offli
def _raylib_target_fps(fps: int) -> int:
return 0 if OFFSCREEN else fps
return 0 if OFFSCREEN or (DEVICE_TYPE == "mici" and not PC) else fps
GL_VERSION = """
#version 300 es
@@ -195,6 +195,14 @@ class MouseEvent(NamedTuple):
t: float
class FrameTiming(NamedTuple):
frame_ms: float = 0.0
cpu_ms: float = 0.0
draw_ms: float = 0.0
update_ms: float = 0.0
present_ms: float = 0.0
class DesktopMouseSample(NamedTuple):
pos: MousePos
left_pressed: bool
@@ -433,7 +441,7 @@ class MouseState:
self._append_mouse_event(event)
return
left_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) # noqa: TID251
left_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
left_pressed = (
rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) or # noqa: TID251
(left_down and not self._desktop_left_down)
@@ -525,6 +533,7 @@ class GuiApplication:
self._last_fps_log_time: float = time.monotonic()
self._burn_in_start_time = time.monotonic()
self._frame = 0
self.frame_timing = FrameTiming()
self._window_close_requested = False
self._nav_stack: list[object] = []
self._nav_stack_ticks: list[Callable[[], None]] = []
@@ -1031,6 +1040,8 @@ class GuiApplication:
self._render_profiler.enable()
while not (self._window_close_requested or rl.window_should_close()):
frame_start = time.monotonic()
cpu_start = time.thread_time()
self._mark_progress("gui_app.loop_start")
self._apply_render_mode()
if PC:
@@ -1087,12 +1098,18 @@ class GuiApplication:
# Only render top widgets
self._mark_progress("gui_app.before_widget_render")
for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]:
viewport = rl.Rectangle(0, 0, self.width, self.height)
widgets = self._nav_stack[-self._nav_stack_widgets_to_render:]
if len(widgets) > 1 and widgets[-1].covers_background(viewport):
widgets = widgets[-1:]
for widget in widgets:
widget.render(rl.Rectangle(0, 0, self.width, self.height))
self._mark_progress("gui_app.after_widget_render")
self._mark_progress("gui_app.frame_ready")
draw_end = time.monotonic()
yield True
update_end = time.monotonic()
if needs_render_transform:
rl.rl_pop_matrix()
@@ -1136,6 +1153,7 @@ class GuiApplication:
self._mark_progress("gui_app.before_end_drawing")
rl.end_drawing()
self._mark_progress("gui_app.after_end_drawing")
present_end = time.monotonic()
self._populate_render_texture_cache()
if RECORD:
@@ -1145,6 +1163,13 @@ class GuiApplication:
self._ffmpeg_queue.put(data) # Async write via background thread
rl.unload_image(image)
self.frame_timing = FrameTiming(
(time.monotonic() - frame_start) * 1000,
(time.thread_time() - cpu_start) * 1000,
(draw_end - frame_start) * 1000,
(update_end - draw_end) * 1000,
(present_end - update_end) * 1000,
)
self._monitor_fps()
self._frame += 1
self._mark_progress("gui_app.loop_idle")
@@ -1282,7 +1307,10 @@ class GuiApplication:
if fps < self._target_fps * FPS_DROP_THRESHOLD:
current_time = time.monotonic()
if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL:
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}")
timing = self.frame_timing
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps} " +
f"(frame={timing.frame_ms:.1f}ms cpu={timing.cpu_ms:.1f}ms " +
f"draw={timing.draw_ms:.1f}ms update={timing.update_ms:.1f}ms present={timing.present_ms:.1f}ms)")
self._last_fps_log_time = current_time
# Strict mode: terminate UI if FPS drops too much
+7 -7
View File
@@ -186,7 +186,7 @@ def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
def triangulate(pts: np.ndarray) -> np.ndarray:
"""Only supports simple polygons with two chains (ribbon)."""
# interleave points to produce a triangle strip
@@ -194,10 +194,10 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
pts = pts[:-1]
half = len(pts) // 2
tri_strip = np.empty_like(pts)
tri_strip = np.empty(pts.shape, dtype=np.float32)
tri_strip[0::2] = pts[:half]
tri_strip[1::2] = pts[half:][::-1]
return cast(list, tri_strip.tolist())
return tri_strip
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
@@ -210,15 +210,15 @@ def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
if len(points) < 3:
return
# Ensure (N,2) float32 contiguous array
pts = np.ascontiguousarray(points, dtype=np.float32)
pts = np.asarray(points)
assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)"
# Triangulate via interleaving
tri_strip = triangulate(pts)
vertices = rl.ffi.from_buffer("Vector2 *", tri_strip)
if gradient is None:
rl.draw_triangle_strip(tri_strip, len(tri_strip), color or rl.WHITE)
rl.draw_triangle_strip(vertices, len(tri_strip), color or rl.WHITE)
return
state = ShaderState.get_instance()
@@ -228,7 +228,7 @@ def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.draw_triangle_strip(vertices, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
+68 -1
View File
@@ -1,12 +1,23 @@
from importlib.resources import as_file
from types import SimpleNamespace
import pytest
from openpilot.system.ui.lib import application
def test_raylib_target_fps_limits_mici(monkeypatch):
def test_raylib_target_fps_uses_mici_display_refresh(monkeypatch):
monkeypatch.setattr(application, "OFFSCREEN", False)
monkeypatch.setattr(application, "DEVICE_TYPE", "mici")
monkeypatch.setattr(application, "PC", False)
assert application._raylib_target_fps(60) == 0
def test_raylib_target_fps_limits_mici_desktop_preview(monkeypatch):
monkeypatch.setattr(application, "OFFSCREEN", False)
monkeypatch.setattr(application, "DEVICE_TYPE", "mici")
monkeypatch.setattr(application, "PC", True)
assert application._raylib_target_fps(60) == 60
@@ -25,6 +36,62 @@ def test_raylib_target_fps_disables_limit_for_offscreen(monkeypatch):
assert application._raylib_target_fps(60) == 0
@pytest.mark.parametrize("covered", [False, True])
def test_render_preserves_visible_layers_and_measures_complete_frame(monkeypatch, covered):
monkeypatch.setattr(application, "PC", False)
monkeypatch.setattr(application, "RECORD", False)
monkeypatch.setattr(application.GuiApplication, "_set_log_callback", lambda _: None)
app = application.GuiApplication(536, 240)
app._scale = 1.0
app._nav_stack_widgets_to_render = 2
app._mouse = SimpleNamespace(get_events=list)
app._burn_in_shift = lambda: (0, 0)
app._monitor_fps = lambda: None
app._show_fps = app._show_touches = False
app._grid_size = app._profile_render_frames = 0
clock = SimpleNamespace(wall=10.0, cpu=1.0)
monkeypatch.setattr(application.time, "monotonic", lambda: clock.wall)
monkeypatch.setattr(application.time, "thread_time", lambda: clock.cpu)
monkeypatch.setattr(application.rl, "window_should_close", lambda: False)
monkeypatch.setattr(application.rl, "begin_drawing", lambda: None)
monkeypatch.setattr(application.rl, "clear_background", lambda _: None)
rendered = []
def draw(name):
rendered.append(name)
clock.wall += 0.004
clock.cpu += 0.003
def present():
clock.wall += 0.011
clock.cpu += 0.001
def populate_cache():
clock.wall += 0.003
clock.cpu += 0.002
monkeypatch.setattr(application.rl, "end_drawing", present)
app._populate_render_texture_cache = populate_cache
app._nav_stack = [
SimpleNamespace(render=lambda _: draw("road")),
SimpleNamespace(covers_background=lambda _: covered, render=lambda _: draw("panel")),
]
frames = app.render()
assert next(frames)
clock.wall += 0.002
clock.cpu += 0.001
app.request_close()
with pytest.raises(StopIteration):
next(frames)
assert rendered == (["panel"] if covered else ["road", "panel"])
draws = len(rendered)
assert app.frame_timing == pytest.approx(application.FrameTiming(
draws * 4 + 16, draws * 3 + 4, draws * 4, 2, 11,
))
def test_burn_in_shift_transitions_between_positions(monkeypatch):
app = object.__new__(application.GuiApplication)
app._burn_in_start_time = 100.0
@@ -1,5 +1,6 @@
import pyray as rl
import pytest
from types import SimpleNamespace
from openpilot.system.ui import widgets
from openpilot.system.ui.lib import scroll_panel2
@@ -32,7 +33,7 @@ def make_scroller(monkeypatch):
monkeypatch.setattr(gui_app, "_show_touches", False)
monkeypatch.setattr(gui_app, "_mouse_events", [])
monkeypatch.setattr(widgets, "PC", False)
monkeypatch.setattr(widgets.device, "awake", True)
monkeypatch.setattr(widgets, "device", SimpleNamespace(awake=True))
monkeypatch.setattr(scroll_panel2, "TICI", True)
def make(items=None, **kwargs):
+56 -5
View File
@@ -1,6 +1,10 @@
import numpy as np
import pyray as rl
import pytest
from types import SimpleNamespace
from openpilot.system.ui.lib.shader_polygon import triangulate
from openpilot.system.ui.lib import shader_polygon
from openpilot.system.ui.lib.shader_polygon import Gradient, draw_polygon, triangulate
def test_triangulate_interleaves_polygon_chains():
@@ -13,11 +17,11 @@ def test_triangulate_interleaves_polygon_chains():
[10.0, 100.0],
], dtype=np.float32)
assert triangulate(points) == [
np.testing.assert_array_equal(triangulate(points), [
[1.0, 10.0], [10.0, 100.0],
[2.0, 20.0], [20.0, 200.0],
[3.0, 30.0], [30.0, 300.0],
]
])
def test_triangulate_drops_unpaired_last_point():
@@ -29,7 +33,54 @@ def test_triangulate_drops_unpaired_last_point():
[99.0, 99.0],
], dtype=np.float32)
assert triangulate(points) == [
np.testing.assert_array_equal(triangulate(points), [
[1.0, 10.0], [10.0, 100.0],
[2.0, 20.0], [20.0, 200.0],
]
])
@pytest.mark.parametrize("points", [
np.arange(132, dtype=np.float32).reshape(-1, 2),
np.arange(132, dtype=np.float64).reshape(-1, 2),
np.arange(264, dtype=np.float64).reshape(-1, 2)[::2, ::-1],
np.asfortranarray(np.arange(132, dtype=np.float32).reshape(-1, 2)),
])
def test_triangulate_preserves_coordinates_in_contiguous_float_buffer(points):
original = points.copy()
points.flags.writeable = False
strip = triangulate(points)
expected = [point for pair in zip(points[:len(points) // 2], points[len(points) // 2:][::-1], strict=True) for point in pair]
assert strip.dtype == np.float32
assert strip.flags.c_contiguous
np.testing.assert_array_equal(strip, np.asarray(expected, dtype=np.float32))
np.testing.assert_array_equal(points, original)
@pytest.mark.parametrize("use_gradient", [False, True])
@pytest.mark.parametrize("point_count", [4, 5, 66])
def test_draw_polygon_passes_native_vertices(monkeypatch, use_gradient, point_count):
points = np.arange(point_count * 4, dtype=np.float64).reshape(-1, 2)[::2]
rect = rl.Rectangle(0, 0, 100, 100)
color = rl.Color(10, 20, 30, 40)
gradient = Gradient((0, 0), (1, 1), [color, rl.Color(*rl.WHITE)], [0, 1]) if use_gradient else None
calls = []
state = SimpleNamespace(initialize=lambda: calls.append("initialize"), shader="shader")
def draw_strip(vertices, count, fill):
assert rl.ffi.typeof(vertices) == rl.ffi.typeof("Vector2 *")
coordinates = [[vertices[i].x, vertices[i].y] for i in range(count)]
np.testing.assert_array_equal(coordinates, triangulate(points))
assert fill == (rl.WHITE if use_gradient else color)
calls.append("draw")
monkeypatch.setattr(shader_polygon.ShaderState, "get_instance", lambda: state)
monkeypatch.setattr(shader_polygon, "_configure_shader_color", lambda *args: calls.append(("configure", args)))
monkeypatch.setattr(rl, "begin_shader_mode", lambda shader: calls.append(("begin", shader)))
monkeypatch.setattr(rl, "end_shader_mode", lambda: calls.append("end"))
monkeypatch.setattr(rl, "draw_triangle_strip", draw_strip)
draw_polygon(rect, points, gradient=gradient, color=None if use_gradient else color)
assert calls == (["initialize", ("configure", (state, None, gradient, rect)), ("begin", "shader"), "draw", "end"]
if use_gradient else ["draw"])
+128
View File
@@ -0,0 +1,128 @@
import pyray as rl
import pytest
from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
class NavScreen(NavWidget):
def _render(self, _):
pass
class TransparentScreen(Widget):
def _render(self, _):
pass
@pytest.fixture
def viewport():
return rl.Rectangle(0, 0, gui_app.width, gui_app.height)
@pytest.fixture
def screen(monkeypatch, viewport):
monkeypatch.setattr(rl, "draw_rectangle_rec", lambda *_: None)
monkeypatch.setattr(rl, "get_time", lambda: 10.0)
monkeypatch.setattr(gui_app, "_show_touches", False)
monkeypatch.setattr(gui_app, "_mouse_events", [])
screen = NavScreen()
screen.set_rect(viewport)
monkeypatch.setattr(screen._nav_bar, "render", lambda: None)
return screen
def touch(monkeypatch, y, *, pressed=False, released=False):
event = MouseEvent(MousePos(20, y), 0, pressed, released, not released, 10.0)
monkeypatch.setattr(gui_app, "_mouse_events", [event])
monkeypatch.setattr(gui_app, "_last_mouse_event", event)
def test_only_settled_opaque_navigation_covers_background(screen, viewport):
assert screen.covers_background(viewport)
assert not TransparentScreen().covers_background(viewport)
class TransparentNav(NavScreen):
def _layout(self):
pass
transparent = TransparentNav()
transparent.set_rect(viewport)
assert not transparent.covers_background(viewport)
screen.set_visible(False)
assert not screen.covers_background(viewport)
@pytest.mark.parametrize("x,y,width,height", [(1, 0, 1, 1), (0, 1, 1, 1), (0, 0, 0.5, 1), (0, 0, 1, 0.5)])
def test_partial_navigation_does_not_cover_background(screen, viewport, x, y, width, height):
screen.set_rect(rl.Rectangle(x, y, viewport.width * width, viewport.height * height))
assert not screen.covers_background(viewport)
@pytest.mark.parametrize("position,velocity", [(0.1, 0), (-0.1, 0), (0, 0.1), (0, -0.1)])
def test_bounce_keeps_background_visible(screen, viewport, position, velocity):
screen._y_pos_filter.x = position
screen._y_pos_filter.velocity.x = velocity
assert not screen.covers_background(viewport)
def test_show_animation_keeps_background_until_settled(screen, viewport):
shown = []
screen.set_shown_callback(lambda: shown.append(True))
screen.show_event()
assert not screen.covers_background(viewport)
for _ in range(300):
screen.render(viewport)
if screen.covers_background(viewport):
break
assert screen.covers_background(viewport)
assert shown == [True]
def test_swipe_uncovers_background_before_first_moving_frame(monkeypatch, screen, viewport):
touch(monkeypatch, 20, pressed=True)
assert screen.covers_background(viewport)
screen.render(viewport)
assert screen.rect.y == 0
touch(monkeypatch, 120)
assert not screen.covers_background(viewport)
screen.render(viewport)
assert screen.rect.y > 0
popped = []
monkeypatch.setattr(gui_app, "pop_widget", lambda: popped.append(True))
touch(monkeypatch, 120, released=True)
screen.render(viewport)
monkeypatch.setattr(gui_app, "_mouse_events", [])
for _ in range(300):
assert not screen.covers_background(viewport)
screen.render(viewport)
if popped:
break
assert popped == [True]
def test_cancelled_swipe_restores_culling_only_after_settling(monkeypatch, screen, viewport):
touch(monkeypatch, 20, pressed=True)
screen.render(viewport)
touch(monkeypatch, 50)
screen.render(viewport)
touch(monkeypatch, 50, released=True)
screen.render(viewport)
assert not screen.covers_background(viewport)
monkeypatch.setattr(gui_app, "_mouse_events", [])
for _ in range(300):
screen.render(viewport)
if screen.covers_background(viewport):
break
assert screen.covers_background(viewport)
assert screen.rect.y == 0
def test_programmatic_dismiss_uncovers_background_before_first_moving_frame(screen, viewport):
screen.dismiss()
assert not screen.covers_background(viewport)
screen.render(viewport)
assert screen.rect.y > 0
+3
View File
@@ -80,6 +80,9 @@ class Widget(abc.ABC):
def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible
def covers_background(self, rect: rl.Rectangle) -> bool:
return False
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
"""Set a callback to be called when the widget is clicked."""
self._click_callback = click_callback
+8
View File
@@ -84,6 +84,14 @@ class NavWidget(Widget, abc.ABC):
def set_shown_callback(self, callback: Callable[[], None] | None) -> None:
self._shown_callback = callback
def covers_background(self, rect: rl.Rectangle) -> bool:
return (self.is_visible and type(self)._layout is NavWidget._layout and
self._rect.x == rect.x == 0 and self._rect.y == rect.y == 0 and
self._rect.width >= rect.width > 0 and self._rect.height >= rect.height > 0 and
self._y_pos_filter.x == 0 and self._y_pos_filter.velocity.x == 0 and
self._drag_start_pos is None and not self._dragging_down and
not self._playing_dismiss_animation and self._shown_callback is None)
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)