diff --git a/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py b/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py index 06981d72d..122d34bdf 100644 --- a/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py +++ b/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py @@ -43,12 +43,28 @@ class FavoriteRadialMenu: PICKER_COLUMNS = 4 PICKER_ROWS = 2 PICKER_PAGE_SIZE = PICKER_COLUMNS * PICKER_ROWS + PICKER_HEADER_HEIGHT = 152.0 + PICKER_FOOTER_HEIGHT = 168.0 + PICKER_GRID_GAP = 24.0 + PICKER_CONTROL_HEIGHT = 96.0 + PICKER_CONTROL_HIT_PAD_Y = 28.0 + PICKER_CARD_PADDING = 26.0 + PICKER_CLOSE_SIZE = 72.0 + PICKER_CLOSE_HIT_SIZE = 150.0 + CORNER_HINT_OUTER_RADIUS = 62.0 + CORNER_HINT_RING_RADIUS = 42.0 + CORNER_HINT_EDGE_MARGIN = 6.0 _PURPLE = (161, 112, 255) _PANEL = rl.Color(13, 11, 23, 236) _PANEL_BORDER = rl.Color(214, 192, 255, 166) _TEXT = rl.Color(255, 255, 255, 245) - _MUTED_TEXT = rl.Color(213, 202, 232, 192) + _MUTED_TEXT = rl.Color(213, 202, 232, 216) + _PICKER_SECTION_LABELS = { + "Visual (Display & UI)": "Display & UI", + "Longitudinal (Speed & Following)": "Speed & Following", + "Lateral (Steering)": "Steering", + } def __init__(self, params: Any, params_memory: Any, option_provider: Callable[[], Iterable[dict[str, Any]]], *, @@ -130,8 +146,9 @@ class FavoriteRadialMenu: self._reset_press_tracking() def corner_center(self, rect: rl.Rectangle) -> rl.Vector2: - zone = self._corner_zone_for(rect) - return rl.Vector2(zone.x + zone.width * 0.40, zone.y + zone.height * 0.60) + scale = self._scale_for(rect) + inset = (self.CORNER_HINT_RING_RADIUS + self.CORNER_HINT_EDGE_MARGIN) * scale + return rl.Vector2(rect.x + inset, rect.y + rect.height - inset) def slot_centers(self, rect: rl.Rectangle) -> list[rl.Vector2]: self._layout(rect) @@ -203,6 +220,17 @@ class FavoriteRadialMenu: def _roundness(rect: rl.Rectangle, radius: float) -> float: return min(1.0, radius / max(1.0, min(rect.width, rect.height) / 2.0)) + @staticmethod + def _snap_render_rect(rect: rl.Rectangle) -> rl.Rectangle: + left = round(rect.x) + top = round(rect.y) + right = round(rect.x + rect.width) + bottom = round(rect.y + rect.height) + return rl.Rectangle( + float(left), float(top), + float(right - left), float(bottom - top), + ) + @staticmethod def _distance(a: Any, b: Any) -> float: return math.hypot(float(a.x - b.x), float(a.y - b.y)) @@ -299,14 +327,14 @@ class FavoriteRadialMenu: ) padding_x = 36.0 * scale - header_height = 126.0 * scale - footer_height = 106.0 * scale - gap_x = 20.0 * scale - gap_y = 20.0 * scale + header_height = self.PICKER_HEADER_HEIGHT * scale + footer_height = self.PICKER_FOOTER_HEIGHT * scale + gap_x = self.PICKER_GRID_GAP * scale + gap_y = self.PICKER_GRID_GAP * scale gap_header_footer = 18.0 * scale # Close button visual & hit bounds - close_size = 70.0 * scale + close_size = self.PICKER_CLOSE_SIZE * scale self._drawer_close_rect = rl.Rectangle( self._drawer_rect.x + self._drawer_rect.width - padding_x - close_size, self._drawer_rect.y + (header_height - close_size) / 2, @@ -314,9 +342,9 @@ class FavoriteRadialMenu: close_size, ) self._drawer_close_hit_rect = rl.Rectangle( - self._drawer_rect.x + self._drawer_rect.width - padding_x - close_size - 18.0 * scale, + self._drawer_rect.x + self._drawer_rect.width - padding_x - self.PICKER_CLOSE_HIT_SIZE * scale, self._drawer_rect.y, - close_size + padding_x + 18.0 * scale, + self.PICKER_CLOSE_HIT_SIZE * scale, header_height, ) @@ -340,7 +368,7 @@ class FavoriteRadialMenu: # Pager buttons in footer control_width = 200.0 * scale - control_height = 74.0 * scale + control_height = self.PICKER_CONTROL_HEIGHT * scale controls_y = self._drawer_rect.y + self._drawer_rect.height - footer_height + (footer_height - control_height) / 2 self._drawer_next_rect = rl.Rectangle( self._drawer_rect.x + self._drawer_rect.width - padding_x - control_width, @@ -350,9 +378,9 @@ class FavoriteRadialMenu: ) self._drawer_next_hit_rect = rl.Rectangle( self._drawer_next_rect.x - 10.0 * scale, - self._drawer_next_rect.y - 12.0 * scale, + self._drawer_next_rect.y - self.PICKER_CONTROL_HIT_PAD_Y * scale, self._drawer_next_rect.width + 20.0 * scale, - self._drawer_next_rect.height + 24.0 * scale, + self._drawer_next_rect.height + self.PICKER_CONTROL_HIT_PAD_Y * scale * 2.0, ) self._drawer_prev_rect = rl.Rectangle( self._drawer_next_rect.x - gap_x - control_width, @@ -362,9 +390,9 @@ class FavoriteRadialMenu: ) self._drawer_prev_hit_rect = rl.Rectangle( self._drawer_prev_rect.x - 10.0 * scale, - self._drawer_prev_rect.y - 12.0 * scale, + self._drawer_prev_rect.y - self.PICKER_CONTROL_HIT_PAD_Y * scale, self._drawer_prev_rect.width + 20.0 * scale, - self._drawer_prev_rect.height + 24.0 * scale, + self._drawer_prev_rect.height + self.PICKER_CONTROL_HIT_PAD_Y * scale * 2.0, ) def _handle_press(self, mouse_pos: Any) -> bool: @@ -428,6 +456,8 @@ class FavoriteRadialMenu: self._reset_press_tracking() return True + release_target = self._target_at(mouse_pos) + is_long_press = self._long_press_fired or ( self._press_start_time is not None and self._clock() - self._press_start_time >= self.LONG_PRESS_SECONDS ) @@ -442,7 +472,7 @@ class FavoriteRadialMenu: self._reset_press_tracking() return True - if self._pressed_target is not None: + if self._pressed_target is not None and release_target == self._pressed_target: self._activate_target(self._pressed_target) elif self._state == self.STATE_PICKER and not self._contains(self._drawer_rect, mouse_pos): self.collapse() @@ -649,13 +679,30 @@ class FavoriteRadialMenu: return label, None return " ".join(line1_words), " ".join(line2_words) + @classmethod + def _picker_section_label(cls, option: dict[str, Any]) -> str: + section = str(option.get("section") or "Favorite").strip() + return cls._PICKER_SECTION_LABELS.get(section, section) + + @staticmethod + def _wrap_picker_text(font: Any, text: str, font_size: int, max_width: float, + max_lines: int = 2) -> list[str]: + from openpilot.system.ui.lib.wrap_text import wrap_text + + text = " ".join(text.split()) + lines = list(wrap_text(font, text, font_size, int(max_width))) + if len(lines) > max_lines: + lines = lines[:max_lines] + lines[-1] = FavoriteRadialMenu._append_ellipsis(font, lines[-1], font_size, max_width) + return lines + def _draw_corner_hint(self) -> None: center = self.corner_center(self._rect) scale = self._scale_for(self._rect) purple = self._PURPLE - for radius, alpha in ((62, 16), (48, 28), (36, 48)): + for radius, alpha in ((self.CORNER_HINT_OUTER_RADIUS, 16), (48, 28), (36, 48)): rl.draw_circle_v(center, radius * scale, rl.Color(*purple, alpha)) - rl.draw_ring(center, 38 * scale, 42 * scale, 0, 360, 32, rl.Color(*purple, 150)) + rl.draw_ring(center, 38 * scale, self.CORNER_HINT_RING_RADIUS * scale, 0, 360, 32, rl.Color(*purple, 150)) tail = rl.Vector2(center.x - 18 * scale, center.y + 18 * scale) tip = rl.Vector2(center.x + 26 * scale, center.y - 26 * scale) @@ -698,13 +745,18 @@ class FavoriteRadialMenu: pad = 2.0 * scale chassis_rect = rl.Rectangle(blade_rect.x + pad, blade_rect.y + pad, blade_rect.width - pad * 2.0, blade_rect.height - pad * 2.0) + draw_rect = self._snap_render_rect(chassis_rect) + # Blades float over the animated road/model layers. Keep their chassis + # opaque so bright path pixels cannot read as gaps at curved ends. if configured: - rl.draw_rectangle_rounded(chassis_rect, 0.45, 14, rl.Color(14, 10, 26, 248)) + rl.draw_rectangle_rounded(draw_rect, 0.45, 14, rl.Color(14, 10, 26, 255)) border_col = rl.Color(214, 192, 255, 230) if is_pressed else rl.Color(161, 112, 255, 140) - rl.draw_rectangle_rounded_lines_ex(chassis_rect, 0.45, 14, 1.8 * scale, border_col) + border_width = max(1, int(round(1.8 * scale))) + rl.draw_rectangle_rounded_lines_ex(draw_rect, 0.45, 14, border_width, border_col) else: - rl.draw_rectangle_rounded(chassis_rect, 0.45, 14, rl.Color(12, 10, 22, 195)) - rl.draw_rectangle_rounded_lines_ex(chassis_rect, 0.45, 14, 1.4 * scale, rl.Color(161, 112, 255, 80)) + rl.draw_rectangle_rounded(draw_rect, 0.45, 14, rl.Color(12, 10, 22, 255)) + border_width = max(1, int(round(1.4 * scale))) + rl.draw_rectangle_rounded_lines_ex(draw_rect, 0.45, 14, border_width, rl.Color(161, 112, 255, 80)) # 2. Left Complication Hub Disc rl.draw_circle_v(center, node_r + 12.0 * scale, rl.Color(*purple, 22 if configured else 12)) @@ -872,17 +924,16 @@ class FavoriteRadialMenu: rl.draw_rectangle_rounded_lines_ex(self._drawer_rect, self._roundness(self._drawer_rect, 34 * scale), 16, 2 * scale, self._PANEL_BORDER) - title = f"Choose Favorite {self._selected_slot + 1}" if self._selected_slot is not None else "Choose Favorite" + title = f"Assign Favorite {self._selected_slot + 1}" if self._selected_slot is not None else "Assign Favorite" title_pos = rl.Vector2(self._drawer_rect.x + 36 * scale, self._drawer_rect.y + 26 * scale) self._draw_text(self._font(bold=True), title, title_pos, int(50 * scale), self._TEXT) - page_count = max(1, math.ceil(len(self._picker_options) / self.PICKER_PAGE_SIZE)) - subtitle = f"Available Galaxy options - Page {self._picker_page + 1} of {page_count}" + subtitle = "Choose a shortcut" self._draw_text( self._font(bold=False), subtitle, - rl.Vector2(title_pos.x, title_pos.y + 56 * scale), int(26 * scale), self._MUTED_TEXT, + rl.Vector2(title_pos.x, title_pos.y + 72 * scale), int(26 * scale), self._MUTED_TEXT, ) - self._draw_close_icon(self._drawer_close_rect) + self._draw_close_icon(self._drawer_close_rect, pressed=self._pressed_target == ("close", None)) if not self._option_rects: empty_text = "No selectable favorites are available for this vehicle." self._draw_centered_text( @@ -894,10 +945,17 @@ class FavoriteRadialMenu: page_start = self._picker_page * self.PICKER_PAGE_SIZE for option_index, option_rect in self._option_rects: option = self._picker_options[option_index] - self._draw_option_card(option_rect, option, scale) + self._draw_option_card(option_rect, option, scale, + pressed=self._pressed_target == ("option", option_index)) - self._draw_pager_button(self._drawer_prev_rect, "Previous", enabled=self._picker_page > 0, scale=scale) - self._draw_pager_button(self._drawer_next_rect, "Next", enabled=self._has_next_picker_page, scale=scale) + self._draw_pager_button( + self._drawer_prev_rect, "Previous", enabled=self._picker_page > 0, scale=scale, + pressed=self._pressed_target == ("previous", None), + ) + self._draw_pager_button( + self._drawer_next_rect, "Next", enabled=self._has_next_picker_page, scale=scale, + pressed=self._pressed_target == ("next", None), + ) first_option = page_start + 1 if self._picker_options else 0 last_option = min(page_start + self.PICKER_PAGE_SIZE, len(self._picker_options)) page_label = f"{first_option}–{last_option} of {len(self._picker_options)}" @@ -908,11 +966,13 @@ class FavoriteRadialMenu: int(26 * scale), self._font(bold=False), self._MUTED_TEXT, ) - def _draw_option_card(self, rect: rl.Rectangle, option: dict[str, Any], scale: float) -> None: - rl.draw_rectangle_rounded(rect, self._roundness(rect, 22 * scale), 12, rl.Color(27, 23, 40, 246)) + def _draw_option_card(self, rect: rl.Rectangle, option: dict[str, Any], scale: float, *, pressed: bool = False) -> None: + card_fill = rl.Color(43, 34, 62, 250) if pressed else rl.Color(27, 23, 40, 246) + card_border = rl.Color(214, 192, 255, 220) if pressed else rl.Color(*self._PURPLE, 116) + rl.draw_rectangle_rounded(rect, self._roundness(rect, 22 * scale), 12, card_fill) rl.draw_rectangle_rounded_lines_ex(rect, self._roundness(rect, 22 * scale), 12, 1.5 * scale, - rl.Color(*self._PURPLE, 116)) - padding = 24.0 * scale + card_border) + padding = self.PICKER_CARD_PADDING * scale key = str(option.get("key") or "") ui_type = str(option.get("ui_type") or "") @@ -927,7 +987,7 @@ class FavoriteRadialMenu: else: badge_label = "TOGGLE" - badge_fs = int(17 * scale) + badge_fs = int(18 * scale) badge_dim = self._measure_text(self._font(bold=True), badge_label, badge_fs) badge_pad_h = 10.0 * scale badge_pad_v = 5.0 * scale @@ -953,61 +1013,64 @@ class FavoriteRadialMenu: content_w = rect.width - 2 * padding section_max_w = content_w - badge_w - 12.0 * scale - section = self._fit_text(self._font(bold=False), str(option.get("section") or "Favorite"), int(24 * scale), section_max_w) + section = self._fit_picker_text(self._font(bold=False), self._picker_section_label(option), int(22 * scale), section_max_w) self._draw_text(self._font(bold=False), section, rl.Vector2(rect.x + padding, rect.y + 24 * scale), - int(24 * scale), rl.Color(200, 172, 255, 214)) + int(22 * scale), rl.Color(200, 172, 255, 214)) - raw_label = str(option.get("label") or option.get("key") or "Favorite").replace('\\"', '"') + raw_label = str(option.get("picker_label") or option.get("label") or option.get("key") or "Favorite").replace('\\"', '"') title_fs = int(36 * scale) title_dim = self._measure_text(self._font(bold=True), raw_label, title_fs) if title_dim.x <= content_w: self._draw_text(self._font(bold=True), raw_label, rl.Vector2(rect.x + padding, rect.y + 84 * scale), title_fs, self._TEXT) else: - l1, l2 = self._semantic_split_label(raw_label, max_chars=20) - if l2 is None: - fitted = self._fit_text(self._font(bold=True), l1, title_fs, content_w) + title_lines = self._wrap_picker_text(self._font(bold=True), raw_label, title_fs, content_w) + if len(title_lines) <= 1: + fitted = self._fit_picker_text(self._font(bold=True), title_lines[0] if title_lines else raw_label, title_fs, content_w) self._draw_text(self._font(bold=True), fitted, rl.Vector2(rect.x + padding, rect.y + 84 * scale), title_fs, self._TEXT) else: fs_l1 = int(34 * scale) fs_l2 = int(30 * scale) - f_l1 = self._fit_text(self._font(bold=True), l1, fs_l1, content_w) - f_l2 = self._fit_text(self._font(bold=True), l2, fs_l2, content_w) + f_l1 = self._fit_picker_text(self._font(bold=True), title_lines[0], fs_l1, content_w) + f_l2 = self._fit_picker_text(self._font(bold=True), title_lines[1], fs_l2, content_w) self._draw_text(self._font(bold=True), f_l1, rl.Vector2(rect.x + padding, rect.y + 76 * scale), fs_l1, self._TEXT) self._draw_text(self._font(bold=True), f_l2, rl.Vector2(rect.x + padding, rect.y + 118 * scale), fs_l2, rl.Color(255, 255, 255, 235)) - raw_desc = str(option.get("description") or "").replace('\\"', '"') - desc_fs = int(24 * scale) + raw_desc = str(option.get("picker_description") or option.get("description") or "").replace('\\"', '"') + desc_fs = int(26 * scale) if raw_desc: - d1, d2 = self._semantic_split_label(raw_desc, max_chars=28) - if d2 is None: - fitted_d = self._fit_text(self._font(bold=False), d1, desc_fs, content_w) - self._draw_text(self._font(bold=False), fitted_d, - rl.Vector2(rect.x + padding, rect.y + rect.height - 48 * scale), - desc_fs, self._MUTED_TEXT) - else: - fd1 = self._fit_text(self._font(bold=False), d1, desc_fs, content_w) - fd2 = self._fit_text(self._font(bold=False), d2, desc_fs, content_w) - self._draw_text(self._font(bold=False), fd1, - rl.Vector2(rect.x + padding, rect.y + rect.height - 72 * scale), - desc_fs, self._MUTED_TEXT) - self._draw_text(self._font(bold=False), fd2, - rl.Vector2(rect.x + padding, rect.y + rect.height - 40 * scale), - desc_fs, self._MUTED_TEXT) + desc_font = self._font(bold=False) + description_lines = self._wrap_picker_text(desc_font, raw_desc, desc_fs, content_w) + desc_dim = self._measure_text(desc_font, "Ag", desc_fs) + line_step = max(36.0 * scale, desc_dim.y + 8.0 * scale) + last_y = rect.y + rect.height - padding - desc_dim.y + first_y = last_y - line_step * (len(description_lines) - 1) + for line_index, description_line in enumerate(description_lines): + self._draw_text( + desc_font, + description_line, + rl.Vector2(rect.x + padding, first_y + line_index * line_step), + desc_fs, + self._MUTED_TEXT, + ) - def _draw_close_icon(self, rect: rl.Rectangle) -> None: + def _draw_close_icon(self, rect: rl.Rectangle, *, pressed: bool = False) -> None: scale = self._scale_for(self._rect) - rl.draw_rectangle_rounded(rect, self._roundness(rect, 18 * scale), 10, rl.Color(255, 255, 255, 22)) + background_alpha = 46 if pressed else 22 + rl.draw_rectangle_rounded(rect, self._roundness(rect, 18 * scale), 10, rl.Color(255, 255, 255, background_alpha)) center = rl.Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2) half = rect.width * 0.22 - color = rl.Color(255, 255, 255, 230) + color = rl.Color(255, 255, 255, 250 if pressed else 230) rl.draw_line_ex(rl.Vector2(center.x - half, center.y - half), rl.Vector2(center.x + half, center.y + half), 4.5 * scale, color) rl.draw_line_ex(rl.Vector2(center.x - half, center.y + half), rl.Vector2(center.x + half, center.y - half), 4.5 * scale, color) - def _draw_pager_button(self, rect: rl.Rectangle, label: str, *, enabled: bool, scale: float) -> None: - fill = rl.Color(68, 44, 112, 225) if enabled else rl.Color(255, 255, 255, 14) - border = rl.Color(*self._PURPLE, 180 if enabled else 42) + def _draw_pager_button(self, rect: rl.Rectangle, label: str, *, enabled: bool, scale: float, + pressed: bool = False) -> None: + fill = rl.Color(94, 64, 148, 238) if pressed and enabled else ( + rl.Color(68, 44, 112, 225) if enabled else rl.Color(255, 255, 255, 14) + ) + border = rl.Color(*self._PURPLE, 220 if pressed and enabled else (180 if enabled else 42)) text_color = self._TEXT if enabled else rl.Color(255, 255, 255, 88) rl.draw_rectangle_rounded(rect, self._roundness(rect, 20 * scale), 10, fill) rl.draw_rectangle_rounded_lines_ex(rect, self._roundness(rect, 20 * scale), 10, 1.8 * scale, border) @@ -1028,6 +1091,28 @@ class FavoriteRadialMenu: shortened = shortened[:-1] return f"{shortened}{ellipsis}" if shortened else ellipsis + @staticmethod + def _fit_picker_text(font: Any, text: str, font_size: int, max_width: float) -> str: + if max_width <= 0: + return "" + if FavoriteRadialMenu._measure_text(font, text, font_size).x <= max_width: + return text + return FavoriteRadialMenu._append_ellipsis(font, text, font_size, max_width) + + @staticmethod + def _append_ellipsis(font: Any, text: str, font_size: int, max_width: float) -> str: + ellipsis = "…" + if max_width <= 0 or FavoriteRadialMenu._measure_text(font, ellipsis, font_size).x > max_width: + return "" + + words = text.strip().rstrip(".…").split() + while words: + shortened = " ".join(words) + if FavoriteRadialMenu._measure_text(font, f"{shortened}{ellipsis}", font_size).x <= max_width: + return f"{shortened}{ellipsis}" + words.pop() + return ellipsis + @staticmethod def _draw_centered_text(text: str, center: rl.Vector2, font_size: int, font: Any, color: rl.Color) -> None: size = FavoriteRadialMenu._measure_text(font, text, font_size) diff --git a/selfdrive/ui/tests/test_favorite_radial_menu.py b/selfdrive/ui/tests/test_favorite_radial_menu.py index 49638b778..ef3aec551 100644 --- a/selfdrive/ui/tests/test_favorite_radial_menu.py +++ b/selfdrive/ui/tests/test_favorite_radial_menu.py @@ -1,3 +1,4 @@ +import sys from types import SimpleNamespace import pyray as rl @@ -70,6 +71,12 @@ def _rect_center(rect): return SimpleNamespace(x=rect.x + rect.width / 2, y=rect.y + rect.height / 2) +def _open_picker(menu, rect, slot_index=0): + _tap(menu, rect, menu.corner_center(rect)) + _tap(menu, rect, menu.slot_centers(rect)[slot_index]) + assert menu.state == FavoriteRadialMenu.STATE_PICKER + + def _menu(clock, options=None): params = FakeParams() memory = FakeParams() @@ -94,6 +101,20 @@ def test_radial_menu_opens_from_corner_tap_and_arranges_three_slots_on_an_arc(): assert centers[0].y < centers[1].y < centers[2].y +def test_corner_hint_keeps_visible_ring_near_content_corner(): + clock = [0.0] + menu, _params, _memory = _menu(clock) + rect = rl.Rectangle(0, 0, 2160, 1080) + + scale = menu._scale_for(rect) + center = menu.corner_center(rect) + ring_radius = menu.CORNER_HINT_RING_RADIUS * scale + edge_margin = menu.CORNER_HINT_EDGE_MARGIN * scale + + assert center.x - ring_radius == rect.x + edge_margin + assert rect.y + rect.height - center.y - ring_radius == edge_margin + assert menu._contains(menu._corner_touch_zone_for(rect), center) + def test_radial_menu_opens_from_diagonal_inward_swipe_and_auto_collapses_after_six_seconds(): clock = [0.0] @@ -213,6 +234,308 @@ def test_picker_pages_keep_all_available_options_selectable(): assert params.get(FAVORITE_SLOTS_PARAM)[0]["key"] == "Option08" +def test_picker_geometry_fits_the_production_content_rect_without_card_overlap(): + clock = [0.0] + options = [ + {"key": f"Option{index}", "label": f"Option {index}", "description": "Test option", "section": "Testing"} + for index in range(8) + ] + menu, _params, _memory = _menu(clock, options=options) + rect = rl.Rectangle(30, 30, 2100, 1020) + + _open_picker(menu, rect) + + drawer = menu._drawer_rect + assert drawer.x >= rect.x + assert drawer.y >= rect.y + assert drawer.x + drawer.width <= rect.x + rect.width + assert drawer.y + drawer.height <= rect.y + rect.height + assert len(menu._option_rects) == 8 + + cards = [option_rect for _index, option_rect in menu._option_rects] + for card in cards: + assert card.x >= drawer.x + assert card.y >= drawer.y + assert card.x + card.width <= drawer.x + drawer.width + assert card.y + card.height <= drawer.y + drawer.height + assert card.width >= 440 + assert card.height >= 250 + + for index, first in enumerate(cards): + for second in cards[index + 1:]: + horizontal_gap = max(first.x, second.x) - min(first.x + first.width, second.x + second.width) + vertical_gap = max(first.y, second.y) - min(first.y + first.height, second.y + second.height) + assert horizontal_gap > 0 or vertical_gap > 0 + + footer_top = drawer.y + drawer.height - menu.PICKER_FOOTER_HEIGHT * menu._scale_for(rect) + assert menu._drawer_prev_rect.y >= footer_top + assert menu._drawer_next_rect.y >= footer_top + + +def test_picker_control_hit_targets_are_tall_enough_for_production_scale(): + clock = [0.0] + options = [ + {"key": f"Option{index}", "label": f"Option {index}", "description": "Test option", "section": "Testing"} + for index in range(9) + ] + menu, _params, _memory = _menu(clock, options=options) + rect = rl.Rectangle(30, 30, 2100, 1020) + + _open_picker(menu, rect) + minimum_hit_height = 144.0 * menu._scale_for(rect) + + assert menu._drawer_prev_hit_rect.height >= minimum_hit_height + assert menu._drawer_next_hit_rect.height >= minimum_hit_height + assert menu._drawer_close_hit_rect.height >= minimum_hit_height + + +def test_picker_description_uses_measured_wrap_and_truncates_to_two_lines(monkeypatch): + clock = [0.0] + long_description = "This description is deliberately long enough to require measured wrapping." + option = { + "key": "MeasuredDescription", + "label": "Measured Description", + "description": long_description, + "section": "Testing", + } + menu, _params, _memory = _menu(clock, options=[option]) + rect = rl.Rectangle(30, 30, 2100, 1020) + _open_picker(menu, rect) + card_rect = menu._option_rects[0][1] + + wrap_calls = [] + drawn_texts = [] + + def fake_wrap(font, text, font_size, max_width, spacing=0): + wrap_calls.append((text, font_size, max_width)) + return ["Measured first line", "Measured second line", "Measured overflow"] + + monkeypatch.setitem(sys.modules, "openpilot.system.ui.lib.wrap_text", SimpleNamespace(wrap_text=fake_wrap)) + monkeypatch.setattr(menu, "_font", lambda *, bold: object()) + monkeypatch.setattr(FavoriteRadialMenu, "_measure_text", + staticmethod(lambda _font, text, _font_size: rl.Vector2(max(10, len(text) * 4), 20))) + monkeypatch.setattr(FavoriteRadialMenu, "_draw_text", + staticmethod(lambda _font, text, _pos, _font_size, _color: drawn_texts.append(text))) + monkeypatch.setattr(rl, "draw_rectangle_rounded", lambda *args: None) + monkeypatch.setattr(rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + + menu._draw_option_card(card_rect, option, menu._scale_for(rect)) + + assert len(wrap_calls) == 1 + assert wrap_calls[0][0] == long_description + assert wrap_calls[0][1] >= 24 + assert wrap_calls[0][2] > 0 + assert drawn_texts[-2:] == ["Measured first line", "Measured second line…"] + + +def test_picker_uses_compact_section_labels_without_mutating_catalog_copy(): + clock = [0.0] + menu, _params, _memory = _menu(clock) + + assert menu._picker_section_label({"section": "Longitudinal (Speed & Following)"}) == "Speed & Following" + assert menu._picker_section_label({"section": "Lateral (Steering)"}) == "Steering" + assert menu._picker_section_label({"section": "Visual (Display & UI)"}) == "Display & UI" + assert menu._picker_section_label({"section": "Testing"}) == "Testing" + + +def test_picker_fit_text_truncates_at_word_boundary(monkeypatch): + monkeypatch.setattr(FavoriteRadialMenu, "_measure_text", + staticmethod(lambda _font, text, _font_size: rl.Vector2(len(text) * 10, 20))) + + fitted = FavoriteRadialMenu._fit_picker_text(object(), "Longitudinal Speed Following", 24, 140) + + assert fitted == "Longitudinal…" + + +def test_picker_wraps_long_titles_by_measured_words(monkeypatch): + clock = [0.0] + option = { + "key": "LongTitle", + "label": "Fallback Label", + "picker_label": "Adaptive Acceleration Limit", + "description": "", + "section": "Testing", + } + menu, _params, _memory = _menu(clock, options=[option]) + rect = rl.Rectangle(30, 30, 2100, 1020) + _open_picker(menu, rect) + card_rect = menu._option_rects[0][1] + drawn_texts = [] + + def fake_measure(_font, text, _font_size): + width = 1000 if text == "Adaptive Acceleration Limit" else max(10, len(text) * 4) + return rl.Vector2(width, 20) + + def fake_wrap(_font, text, _font_size, _max_width, spacing=0): + assert text == "Adaptive Acceleration Limit" + return ["Adaptive Acceleration", "Limit"] + + monkeypatch.setitem(sys.modules, "openpilot.system.ui.lib.wrap_text", SimpleNamespace(wrap_text=fake_wrap)) + monkeypatch.setattr(menu, "_font", lambda *, bold: object()) + monkeypatch.setattr(FavoriteRadialMenu, "_measure_text", staticmethod(fake_measure)) + monkeypatch.setattr(FavoriteRadialMenu, "_draw_text", + staticmethod(lambda _font, text, _pos, _font_size, _color: drawn_texts.append(text))) + monkeypatch.setattr(rl, "draw_rectangle_rounded", lambda *args: None) + monkeypatch.setattr(rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + + menu._draw_option_card(card_rect, option, menu._scale_for(rect)) + + assert "Adaptive Acceleration" in drawn_texts + assert "Limit" in drawn_texts + assert "Fallback Label" not in drawn_texts + + +def test_picker_prefers_picker_specific_description(monkeypatch): + clock = [0.0] + option = { + "key": "CompactDescription", + "label": "Compact Description", + "description": "The complete settings description should remain available outside the picker.", + "picker_description": "Short picker description.", + "section": "Testing", + } + menu, _params, _memory = _menu(clock, options=[option]) + rect = rl.Rectangle(30, 30, 2100, 1020) + _open_picker(menu, rect) + card_rect = menu._option_rects[0][1] + wrap_inputs = [] + drawn_texts = [] + + def fake_wrap(font, text, font_size, max_width, spacing=0): + wrap_inputs.append(text) + return [text] + + monkeypatch.setitem(sys.modules, "openpilot.system.ui.lib.wrap_text", SimpleNamespace(wrap_text=fake_wrap)) + monkeypatch.setattr(menu, "_font", lambda *, bold: object()) + monkeypatch.setattr(FavoriteRadialMenu, "_measure_text", + staticmethod(lambda _font, text, _font_size: rl.Vector2(max(10, len(text) * 4), 20))) + monkeypatch.setattr(FavoriteRadialMenu, "_draw_text", + staticmethod(lambda _font, text, _pos, _font_size, _color: drawn_texts.append(text))) + monkeypatch.setattr(rl, "draw_rectangle_rounded", lambda *args: None) + monkeypatch.setattr(rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + + menu._draw_option_card(card_rect, option, menu._scale_for(rect)) + + assert wrap_inputs == ["Short picker description."] + assert "Short picker description." in drawn_texts + assert "The complete settings description should remain available outside the picker." not in drawn_texts + + +def test_picker_pressed_card_has_a_distinct_visual_state(monkeypatch): + clock = [0.0] + option = {"key": "PressedOption", "label": "Pressed Option", "description": "", "section": "Testing"} + menu, _params, _memory = _menu(clock, options=[option]) + rect = rl.Rectangle(30, 30, 2100, 1020) + _open_picker(menu, rect) + card_rect = menu._option_rects[0][1] + card_colors = [] + + def capture_card_fill(draw_rect, *_args): + if all(abs(getattr(draw_rect, attr) - getattr(card_rect, attr)) < 0.01 for attr in ("x", "y", "width", "height")): + color = _args[-1] + card_colors.append((color.r, color.g, color.b, color.a)) + + monkeypatch.setattr(menu, "_font", lambda *, bold: object()) + monkeypatch.setattr(FavoriteRadialMenu, "_measure_text", + staticmethod(lambda _font, text, _font_size: rl.Vector2(max(10, len(text) * 4), 20))) + monkeypatch.setattr(FavoriteRadialMenu, "_draw_text", staticmethod(lambda *args: None)) + monkeypatch.setattr(rl, "draw_rectangle_rounded", capture_card_fill) + monkeypatch.setattr(rl, "draw_rectangle_rounded_lines_ex", lambda *args: None) + + menu._draw_option_card(card_rect, option, menu._scale_for(rect), pressed=False) + menu._draw_option_card(card_rect, option, menu._scale_for(rect), pressed=True) + + assert len(card_colors) == 2 + assert card_colors[0] != card_colors[1] + + +def test_picker_release_on_a_different_option_cancels_without_assignment(): + clock = [0.0] + options = [ + {"key": "OptionA", "label": "Option A", "description": "A", "section": "Testing"}, + {"key": "OptionB", "label": "Option B", "description": "B", "section": "Testing"}, + ] + menu, params, _memory = _menu(clock, options=options) + rect = rl.Rectangle(0, 0, 2160, 1080) + _open_picker(menu, rect) + option_a, option_b = menu.option_centers(rect) + + menu.process_mouse_events([_event(option_a.x, option_a.y, pressed=True)], rect) + menu.process_mouse_events([_event(option_b.x, option_b.y, released=True)], rect) + + assert menu.state == FavoriteRadialMenu.STATE_PICKER + assert params.get(FAVORITE_SLOTS_PARAM) is None + + +def test_picker_release_target_must_match_pressed_target(monkeypatch): + clock = [0.0] + menu, _params, _memory = _menu(clock, options=[ + {"key": "OptionA", "label": "Option A", "description": "A", "section": "Testing"}, + {"key": "OptionB", "label": "Option B", "description": "B", "section": "Testing"}, + ]) + rect = rl.Rectangle(0, 0, 2160, 1080) + _open_picker(menu, rect) + press_pos = SimpleNamespace(x=100, y=100) + activated = [] + menu._pressed_target = ("option", 0) + menu._press_start_time = clock[0] + menu._press_start_pos = press_pos + monkeypatch.setattr(menu, "_target_at", lambda _mouse_pos: ("option", 1)) + monkeypatch.setattr(menu, "_activate_target", lambda target: activated.append(target)) + + menu._handle_release(press_pos) + + assert activated == [] + assert menu.state == FavoriteRadialMenu.STATE_PICKER + + +def test_picker_release_outside_after_pressing_an_option_cancels_without_assignment(): + clock = [0.0] + option = {"key": "OptionA", "label": "Option A", "description": "A", "section": "Testing"} + menu, params, _memory = _menu(clock, options=[option]) + rect = rl.Rectangle(0, 0, 2160, 1080) + _open_picker(menu, rect) + option_a = menu.option_centers(rect)[0] + outside = SimpleNamespace(x=rect.x + 5, y=rect.y + 5) + + menu.process_mouse_events([_event(option_a.x, option_a.y, pressed=True)], rect) + menu.process_mouse_events([_event(outside.x, outside.y, released=True)], rect) + + assert menu.state == FavoriteRadialMenu.STATE_PICKER + assert params.get(FAVORITE_SLOTS_PARAM) is None + + +def test_picker_release_on_the_same_option_assigns_it(): + clock = [0.0] + option = {"key": "OptionA", "label": "Option A", "description": "A", "section": "Testing"} + menu, params, _memory = _menu(clock, options=[option]) + rect = rl.Rectangle(0, 0, 2160, 1080) + _open_picker(menu, rect) + + _tap(menu, rect, menu.option_centers(rect)[0]) + + assert menu.state == FavoriteRadialMenu.STATE_RADIAL + assert params.get(FAVORITE_SLOTS_PARAM)[0]["key"] == "OptionA" + + +def test_picker_disabled_previous_stays_open_but_an_untargeted_outside_tap_dismisses(): + clock = [0.0] + options = [ + {"key": f"Option{index:02}", "label": f"Option {index:02}", "description": "Test option", "section": "Testing"} + for index in range(9) + ] + menu, _params, _memory = _menu(clock, options=options) + rect = rl.Rectangle(0, 0, 2160, 1080) + _open_picker(menu, rect) + + _tap(menu, rect, _rect_center(menu._drawer_prev_rect)) + assert menu.state == FavoriteRadialMenu.STATE_PICKER + + outside = SimpleNamespace(x=rect.x + 5, y=rect.y + 5) + _tap(menu, rect, outside) + assert menu.state == FavoriteRadialMenu.STATE_COLLAPSED + + def test_short_tap_toggles_action_and_does_not_enter_edit_mode(): clock = [0.0] menu, params, memory = _menu(clock) @@ -485,6 +808,79 @@ def test_render_during_wave_flash_animation(monkeypatch): assert menu._flash_slot is None +def test_radial_render_rect_snaps_each_boundary(): + rect = rl.Rectangle(10.36, 20.64, 450.75, 103.21) + + snapped = FavoriteRadialMenu._snap_render_rect(rect) + + assert (snapped.x, snapped.y, snapped.width, snapped.height) == (10.0, 21.0, 451.0, 103.0) + + +def test_radial_blade_render_uses_pixel_aligned_chassis_and_border(monkeypatch): + clock = [0.0] + options = [ + {"key": "Compass", "label": "Compass", "ui_type": "toggle", "data_type": "bool"}, + ] + menu, params, _memory = _menu(clock, options=options) + params.types["Compass"] = ParamKeyType.BOOL + params.put("Compass", False) + rect = rl.Rectangle(0, 0, 2160, 1080) + + fills = [] + strokes = [] + monkeypatch.setattr(menu, "_font", lambda *, bold: object()) + monkeypatch.setattr(FavoriteRadialMenu, "_measure_text", + staticmethod(lambda _font, _text, _font_size: rl.Vector2(100.0, 20.0))) + monkeypatch.setattr(FavoriteRadialMenu, "_draw_text", staticmethod(lambda *args: None)) + monkeypatch.setattr(rl, "draw_ring", lambda *args: None) + monkeypatch.setattr(rl, "draw_circle_v", lambda *args: None) + monkeypatch.setattr(rl, "draw_line_ex", lambda *args: None) + monkeypatch.setattr(rl, "draw_rectangle_rounded", + lambda *args: fills.append(args)) + monkeypatch.setattr(rl, "draw_rectangle_rounded_lines_ex", + lambda *args: strokes.append(args)) + + cases = ( + ([], None, 1, 255), + ([{"enabled": True, "show_onroad": True, "key": "Compass", "label": "Compass"}], ("slot", 0), 2, 255), + ) + for slot_payload, pressed_target, expected_border_width, expected_fill_alpha in cases: + params.put(FAVORITE_SLOTS_PARAM, slot_payload) + menu._pressed_target = pressed_target + menu._layout(rect) + fills.clear() + strokes.clear() + + blade_rect = menu._slot_rects[0][5] + chassis_rect = blade_rect + if pressed_target is not None: + pad = 2.0 * menu._scale_for(rect) + chassis_rect = rl.Rectangle( + blade_rect.x + pad, + blade_rect.y + pad, + blade_rect.width - pad * 2.0, + blade_rect.height - pad * 2.0, + ) + expected_rect = menu._snap_render_rect(chassis_rect) + + menu._draw_radial_menu() + + assert fills + assert strokes + expected_tuple = (expected_rect.x, expected_rect.y, expected_rect.width, expected_rect.height) + assert (fills[0][0].x, fills[0][0].y, fills[0][0].width, fills[0][0].height) == expected_tuple + assert (strokes[0][0].x, strokes[0][0].y, strokes[0][0].width, strokes[0][0].height) == expected_tuple + assert strokes[0][3] == expected_border_width + assert fills[0][3].a == expected_fill_alpha + assert all(float(value).is_integer() for value in ( + strokes[0][0].x, + strokes[0][0].y, + strokes[0][0].width, + strokes[0][0].height, + strokes[0][3], + )) + + def test_render_boolean_toggle_switch_and_picker_badges(monkeypatch): clock = [0.0] options = [ @@ -536,6 +932,3 @@ def test_render_boolean_toggle_switch_and_picker_badges(monkeypatch): assert "TOGGLE" in drawn_texts assert "2 STATES" in drawn_texts assert "ACTION" in drawn_texts - - - diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 63cb60f45..d2ff2aad6 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -127,6 +127,7 @@ "key": "AlwaysOnLateral", "label": "Always On Lateral", "description": "openpilot's steering remains active even when the accelerator or brake pedals are pressed.", + "picker_description": "Keeps steering active while the accelerator or brake pedals are pressed.", "data_type": "bool", "ui_type": "toggle", "is_parent_toggle": true, @@ -1242,6 +1243,7 @@ "key": "TacoTune", "label": "\\\"Taco Bell Run\\\" Turn Speed Hack", "description": "The turn-speed hack from comma's 2022 \"Taco Bell Run\". Designed to slow down for left and right turns.", + "picker_description": "Slows down for left and right turns using the Taco Bell Run turn-speed hack.", "data_type": "bool", "ui_type": "toggle", "parent_key": "LongitudinalTune", @@ -2852,6 +2854,7 @@ "key": "NAPAdaptiveAccel", "label": "Adaptive Acceleration Limit", "description": "Reduce maximum acceleration as you close in on a lead vehicle when Tesla pedal-long is active.", + "picker_description": "Reduces maximum acceleration near a lead vehicle when Tesla pedal-long is active.", "data_type": "bool", "ui_type": "toggle", "parent_key": "NAPPedalEnabled", @@ -3961,6 +3964,7 @@ "key": "AlwaysAllowUploads", "label": "Always Allow Uploads", "description": "Override upload blocks and always keep uploader enabled. Advanced use only.", + "picker_description": "Advanced: keep uploader enabled even when upload blocks are active.", "data_type": "bool", "ui_type": "toggle", "parent_key": "NoUploads", @@ -4328,6 +4332,7 @@ "key": "AllowImpossibleAcceleration", "label": "Allow Impossible Acceleration", "description": "WARNING: This suppresses openpilot's excessive longitudinal actuation diagnostic when measured acceleration looks impossible for the requested gas/brake command.\n\nLeave this OFF unless you are intentionally testing edge cases like shifting to neutral and understand that it can hide a real actuation problem.", + "picker_description": "WARNING: Hides the impossible-acceleration diagnostic. Leave OFF unless testing edge cases.", "data_type": "bool", "ui_type": "toggle", "settings_tier": "advanced" diff --git a/starpilot/common/favorite_slots.py b/starpilot/common/favorite_slots.py index 28b9cabfb..9da1b7b02 100644 --- a/starpilot/common/favorite_slots.py +++ b/starpilot/common/favorite_slots.py @@ -159,6 +159,10 @@ def build_favorite_slot_options(is_eligible_param: Callable[[str], bool], *, "data_type": data_type, "requiresCapability": str(param_data.get("requires_capability") or ""), } + for picker_field in ("picker_label", "picker_description"): + picker_value = str(param_data.get(picker_field) or "").strip() + if picker_value: + opt_dict[picker_field] = picker_value if is_dropdown: opt_dict["options"] = [dict(o) for o in raw_options if isinstance(o, dict)] options.append(opt_dict) @@ -522,4 +526,3 @@ def unassign_favorite_slot(slot_index: int, params: Params | None = None, params saved = save_favorite_slots(slots, params, eligible_keys=eligible_keys) request_starpilot_toggle_refresh(params_memory) return saved - diff --git a/starpilot/common/tests/test_favorite_slots.py b/starpilot/common/tests/test_favorite_slots.py index b4bc2d721..342254a03 100644 --- a/starpilot/common/tests/test_favorite_slots.py +++ b/starpilot/common/tests/test_favorite_slots.py @@ -196,7 +196,14 @@ def test_shared_favorite_option_catalog_uses_layout_metadata_and_capability_gate { "name": "Testing", "params": [ - {"key": "FeatureToggle", "label": "Feature Toggle", "description": "Visible", "ui_type": "toggle", "data_type": "bool"}, + { + "key": "FeatureToggle", + "label": "Feature Toggle", + "description": "Visible", + "picker_description": "Compact", + "ui_type": "toggle", + "data_type": "bool", + }, {"key": "UnsupportedToggle", "label": "Unsupported", "ui_type": "toggle", "data_type": "bool"}, {"key": "AlphaLongitudinalEnabled", "label": "Alpha", "ui_type": "toggle", "data_type": "bool"}, {"key": "RivianAngleControl", "label": "Rivian", "ui_type": "toggle", "data_type": "bool", "requires_capability": "HasRivianAngleHarness"}, @@ -212,6 +219,9 @@ def test_shared_favorite_option_catalog_uses_layout_metadata_and_capability_gate assert FAVORITE_ACTION_DISTANCE_DECREASE in {option["key"] for option in options} assert {option["key"] for option in options} >= {"FeatureToggle", "RivianAngleControl"} + feature_option = next(option for option in options if option["key"] == "FeatureToggle") + assert feature_option["description"] == "Visible" + assert feature_option["picker_description"] == "Compact" assert "UnsupportedToggle" not in {option["key"] for option in options} assert "AlphaLongitudinalEnabled" not in {option["key"] for option in options} assert "RivianAngleControl" not in { @@ -372,4 +382,3 @@ def test_get_favorite_param_value_and_get_favorite_values(): assert active_idx == 2 assert active_label == "Sport" assert len(opts) == 3 -