Polish favorite picker

This commit is contained in:
firestarsdog
2026-08-21 02:53:31 -04:00
parent 3cefd789e5
commit 269562e73a
5 changed files with 567 additions and 72 deletions
@@ -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)
+396 -3
View File
@@ -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
@@ -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"
+4 -1
View File
@@ -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
+11 -2
View File
@@ -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