diff --git a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py index cbcf2ffaf5..76ac795129 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py +++ b/selfdrive/ui/onroad/starpilot/widgets/unified_speed.py @@ -38,6 +38,37 @@ UNIFIED_ACCENT = rl.Color(160, 96, 230, 230) OFFSET_COLOR = rl.Color(UNIFIED_ACCENT.r, UNIFIED_ACCENT.g, UNIFIED_ACCENT.b, 255) +def _draw_header_icon(icon_key: str, x: float, y: float) -> None: + scale = max(1.0, gui_app._scale * max(gui_app._pixel_scale_x, gui_app._pixel_scale_y)) + # Supersample for smooth edges. + texture_size = math.ceil(2 * HEADER_ICON_SIZE * scale) + + def render() -> None: + rl.rl_push_matrix() + try: + texture_scale = texture_size / HEADER_ICON_SIZE + rl.rl_scalef(texture_scale, texture_scale, 1.0) + _draw_source_icon(icon_key, 0, 0, HEADER_ICON_SIZE, rl.WHITE) + finally: + rl.rl_pop_matrix() + + texture = gui_app.cached_render_texture( + f"unified-speed-header:{icon_key}:{texture_size}", texture_size, texture_size, render, + ) + if texture is None: + _draw_source_icon(icon_key, x, y, HEADER_ICON_SIZE, rl.WHITE) + return + + rl.begin_blend_mode(rl.BlendMode.BLEND_ALPHA_PREMULTIPLY) + try: + rl.draw_texture_pro( + texture, rl.Rectangle(0, 0, texture_size, -texture_size), + rl.Rectangle(x, y, HEADER_ICON_SIZE, HEADER_ICON_SIZE), rl.Vector2(0, 0), 0.0, rl.WHITE, + ) + finally: + rl.end_blend_mode() + + class UnifiedSpeedWidget(LayoutWidget): TOUCH_SLOP = 20 @@ -112,7 +143,7 @@ class UnifiedSpeedWidget(LayoutWidget): group_x = bounds.x + (bounds.width - group_width) / 2 icon_y = bounds.y + 20 if icon_key: - _draw_source_icon(icon_key, group_x, icon_y, HEADER_ICON_SIZE, rl.WHITE) + _draw_header_icon(icon_key, group_x, icon_y) rl.draw_text_ex( self._font_semi_bold, text, rl.Vector2(group_x + icon_width, icon_y + (HEADER_ICON_SIZE - text_size.y) / 2), diff --git a/selfdrive/ui/tests/test_unified_speed_widget.py b/selfdrive/ui/tests/test_unified_speed_widget.py index 9eed50e34d..581fc59772 100644 --- a/selfdrive/ui/tests/test_unified_speed_widget.py +++ b/selfdrive/ui/tests/test_unified_speed_widget.py @@ -21,6 +21,74 @@ def make_widget(mode="split", pending=False): return widget +@pytest.fixture +def header_icon_cache(monkeypatch): + app = object.__new__(type(unified_speed.gui_app)) + app._scale = app._pixel_scale_x = app._pixel_scale_y = 1.0 + app._cached_render_textures = {} + app._pending_render_textures = {} + geometry, draws, allocations, scales = [], [], [], [] + monkeypatch.setattr(unified_speed, "gui_app", app) + monkeypatch.setattr(unified_speed, "_draw_source_icon", lambda *args: geometry.append(args)) + monkeypatch.setattr(unified_speed, "measure_text_cached", lambda *args: rl.Vector2(100, 28)) + monkeypatch.setattr(rl, "draw_text_ex", lambda *args: None) + monkeypatch.setattr(rl, "draw_texture_pro", lambda *args: draws.append(args)) + monkeypatch.setattr(rl, "rl_scalef", lambda *args: scales.append(args)) + for name in ("rl_push_matrix", "rl_pop_matrix", "begin_texture_mode", "end_texture_mode", "clear_background", + "rl_set_blend_factors_separate", "begin_blend_mode", "end_blend_mode", "set_texture_filter", "set_texture_wrap"): + monkeypatch.setattr(rl, name, lambda *args: None) + + def allocate(width, height): + allocations.append((width, height)) + return SimpleNamespace(texture=SimpleNamespace(width=width, height=height)) + + monkeypatch.setattr(rl, "load_render_texture", allocate) + return app, geometry, draws, allocations, scales + + +def test_header_glyph_cache_is_shared_and_skips_geometry_after_first_frame(header_icon_cache): + app, geometry, draws, allocations, _scales = header_icon_cache + widgets = [make_widget(), make_widget()] + for widget in widgets: + widget._font_semi_bold = None + widget = widgets[0] + for label, icon in (("MAX SET", "speedometer"), ("SPEED LIMIT", "map")): + widget._draw_header(widget.rect, label, icon, rl.WHITE) + assert len(geometry) == 2 + assert allocations == [] + app._populate_render_texture_cache() + assert len(geometry) == 4 + + for frame in range(60): + widget = widgets[frame % 2] + bounds = rl.Rectangle(frame, frame, 260, 250) + widget._draw_header(bounds, "MAX SET", "speedometer", rl.WHITE) + widget._draw_header(bounds, f"LIMIT {frame}", "map", rl.GRAY) + assert len(geometry) == 4 + assert len(draws) == 120 + assert len(allocations) == len(app._cached_render_textures) == 2 + assert app._pending_render_textures == {} + + +@pytest.mark.parametrize("scale,dpi,texture_size", [(0.5, 1.0, 68), (1.0, 2.0, 136), (1.25, 1.5, 128)]) +def test_header_cache_resolution_preserves_logical_geometry(header_icon_cache, scale, dpi, texture_size): + app, geometry, draws, allocations, scales = header_icon_cache + app._scale, app._pixel_scale_x = scale, dpi + for icon in ("speedometer", "map", "camera", "dashboard", "next"): + unified_speed._draw_header_icon(icon, 10, 20) + app._populate_render_texture_cache() + assert len(app._cached_render_textures) == 5 + assert allocations == [(texture_size, texture_size)] * 5 + assert all(args[1:4] == (0, 0, 34) for args in geometry[5:]) + assert scales == [(texture_size / 34, texture_size / 34, 1.0)] * 5 + + unified_speed._draw_header_icon("map", 200, 300) + assert len(geometry) == 10 + source, destination = draws[-1][1:3] + assert (source.width, source.height) == (texture_size, -texture_size) + assert (destination.x, destination.y, destination.width, destination.height) == (200, 300, 34, 34) + + def test_speed_limit_hit_target_is_right_half_in_both_layouts(): for mode in ("split", "merged"): right = make_widget(mode)._speed_limit_bounds(rl.Rectangle(30, 75, 520, 250))