Favorites

This commit is contained in:
firestar5683
2026-07-19 15:45:04 -05:00
parent 5f52120d7a
commit c928e27033
+126 -48
View File
@@ -9,7 +9,7 @@ from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer, ALERT_COLORS, AlertStatus
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer, VISION_SPEED_LIMIT_PULSE_COLOR
from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
from openpilot.selfdrive.ui.mici.onroad.sidebar_widgets import MiciSidebarWidgets
@@ -152,16 +152,22 @@ class BookmarkIcon(Widget):
class FavoriteSlotsOverlay(Widget):
EDGE_MARGIN = 8
BUTTON_GAP = 8
MAX_BUTTON_SIZE = 208
INDICATOR_SIZE = 18
SLOT_COUNT = 3
MAX_TAP_TRAVEL = 24
COLOR_TRANSITION_SECONDS = 0.65
FADE_START_SECONDS = 2.0
FEEDBACK_DURATION_SECONDS = 3.0
def __init__(self):
super().__init__()
self._font = gui_app.font(FontWeight.SEMI_BOLD)
self._button_rects: list[tuple[int, rl.Rectangle]] = []
self._pressed_slot: int | None = None
self._press_pos: MousePos | None = None
self._max_tap_travel = 0.0
self._feedback_slot: int | None = None
self._feedback_started_at = -self.FEEDBACK_DURATION_SECONDS
self._feedback_value: bool | None = None
self._interacting = False
def interacting(self):
@@ -176,20 +182,10 @@ class FavoriteSlotsOverlay(Widget):
return visible
def _slot_rects(self, rect: rl.Rectangle, slots: list[tuple[int, dict]]) -> list[tuple[int, rl.Rectangle]]:
if not slots:
return []
slot_count = len(slots)
available_width = rect.width - (2 * self.EDGE_MARGIN) - ((slot_count - 1) * self.BUTTON_GAP)
available_height = rect.height - (2 * self.EDGE_MARGIN)
button_size = min(self.MAX_BUTTON_SIZE, available_height, available_width / slot_count)
row_width = slot_count * button_size + (slot_count - 1) * self.BUTTON_GAP
x = rect.x + (rect.width - row_width) / 2
y = rect.y + (rect.height - button_size) / 2
slot_width = rect.width / self.SLOT_COUNT
return [
(slot_index, rl.Rectangle(x + draw_index * (button_size + self.BUTTON_GAP), y, button_size, button_size))
for draw_index, (slot_index, _slot) in enumerate(slots)
(slot_index, rl.Rectangle(rect.x + slot_index * slot_width, rect.y, slot_width, rect.height))
for slot_index, _slot in slots
]
def _fit_label(self, label: str, max_width: float, max_height: float) -> tuple[list[str], int]:
@@ -204,41 +200,100 @@ class FavoriteSlotsOverlay(Widget):
return lines, font_size
return lines[:3], 18
@staticmethod
def _ease_out_cubic(progress: float) -> float:
progress = float(np.clip(progress, 0.0, 1.0))
return 1.0 - (1.0 - progress) ** 3
@staticmethod
def _blend_color(start: rl.Color, end: rl.Color, progress: float, alpha: int) -> rl.Color:
progress = float(np.clip(progress, 0.0, 1.0))
return rl.Color(
round(start.r + (end.r - start.r) * progress),
round(start.g + (end.g - start.g) * progress),
round(start.b + (end.b - start.b) * progress),
alpha,
)
def _feedback_alpha(self, elapsed: float) -> float:
if elapsed < self.FADE_START_SECONDS:
return 1.0
fade_duration = self.FEEDBACK_DURATION_SECONDS - self.FADE_START_SECONDS
return float(np.clip(1.0 - (elapsed - self.FADE_START_SECONDS) / fade_duration, 0.0, 1.0))
def _draw_feedback(self, slot_rect: rl.Rectangle, slot: dict, elapsed: float, held: bool) -> None:
alpha_scale = 1.0 if held else self._feedback_alpha(elapsed)
if alpha_scale <= 0.0:
return
color_progress = self._ease_out_cubic(elapsed / self.COLOR_TRANSITION_SECONDS)
alpha = round(255 * alpha_scale)
accent = self._blend_color(VISION_SPEED_LIMIT_PULSE_COLOR, rl.Color(255, 255, 255, 255), color_progress, alpha)
panel_margin = 12
panel_width = max(96.0, slot_rect.width - 2 * panel_margin)
panel_height = min(132.0, slot_rect.height * 0.5)
panel_rect = rl.Rectangle(
slot_rect.x + (slot_rect.width - panel_width) / 2,
slot_rect.y + (slot_rect.height - panel_height) / 2,
panel_width,
panel_height,
)
# A restrained initial glow gives the same purple acknowledgement as the
# vision speed-limit pulse without leaving persistent controls onscreen.
glow_strength = (1.0 - color_progress) * alpha_scale
for expansion, opacity in ((8, 22), (4, 44)):
glow_rect = rl.Rectangle(
panel_rect.x - expansion,
panel_rect.y - expansion,
panel_rect.width + 2 * expansion,
panel_rect.height + 2 * expansion,
)
glow = rl.Color(
VISION_SPEED_LIMIT_PULSE_COLOR.r,
VISION_SPEED_LIMIT_PULSE_COLOR.g,
VISION_SPEED_LIMIT_PULSE_COLOR.b,
round(opacity * glow_strength),
)
rl.draw_rectangle_rounded_lines_ex(glow_rect, 0.16, 12, 2, glow)
rl.draw_rectangle_rounded(panel_rect, 0.16, 12, rl.Color(0, 0, 0, round(178 * alpha_scale)))
rl.draw_rectangle_rounded_lines_ex(panel_rect, 0.16, 12, 3, accent)
label = slot.get("label") or slot.get("key") or "Favorite"
state_text = "ON" if self._feedback_value else "OFF"
lines, font_size = self._fit_label(label, panel_rect.width - 20, panel_rect.height - 46)
line_height = font_size * 1.08
label_height = len(lines) * line_height
text_y = panel_rect.y + 12 + (panel_rect.height - 42 - label_height) / 2
for line in lines:
text_size = measure_text_cached(self._font, line, font_size)
text_x = panel_rect.x + (panel_rect.width - text_size.x) / 2
rl.draw_text_ex(self._font, line, rl.Vector2(text_x, text_y), font_size, 0, accent)
text_y += line_height
state_size = measure_text_cached(self._font, state_text, 20)
state_pos = rl.Vector2(panel_rect.x + (panel_rect.width - state_size.x) / 2, panel_rect.y + panel_rect.height - 30)
rl.draw_text_ex(self._font, state_text, state_pos, 20, 0, rl.Color(255, 255, 255, round(210 * alpha_scale)))
def _render(self, rect: rl.Rectangle):
visible_slots = self._visible_slots()
self._button_rects = self._slot_rects(rect, visible_slots)
slot_by_index = dict(visible_slots)
for slot_index, button_rect in self._button_rects:
slot = slot_by_index[slot_index]
pressed = self._pressed_slot == slot_index
bg = rl.Color(0, 0, 0, 190 if pressed else 166)
border = rl.Color(255, 255, 255, 120 if pressed else 78)
rl.draw_rectangle_rounded(button_rect, 0.18, 12, bg)
rl.draw_rectangle_rounded_lines_ex(button_rect, 0.18, 12, 2, border)
if self._feedback_slot not in slot_by_index:
self._feedback_slot = None
return
key = slot.get("key")
active = ui_state.params.get_bool(key) if key else False
indicator = rl.Color(48, 255, 156, 255) if active else rl.Color(135, 135, 135, 255)
indicator_x = button_rect.x + button_rect.width - self.INDICATOR_SIZE - 12
indicator_y = button_rect.y + 12
rl.draw_circle(int(indicator_x + self.INDICATOR_SIZE / 2), int(indicator_y + self.INDICATOR_SIZE / 2), self.INDICATOR_SIZE / 2, indicator)
elapsed = max(0.0, rl.get_time() - self._feedback_started_at)
held = self._pressed_slot == self._feedback_slot
if not held and elapsed >= self.FEEDBACK_DURATION_SECONDS:
self._feedback_slot = None
return
slot_text = f"#{slot_index + 1}"
rl.draw_text_ex(self._font, slot_text, rl.Vector2(button_rect.x + 12, button_rect.y + 9), 22, 0, rl.Color(255, 255, 255, 180))
lines, font_size = self._fit_label(
slot.get("label") or key or "Favorite",
button_rect.width - 24,
button_rect.height - 54,
)
line_height = font_size * 1.12
text_y = button_rect.y + 38 + (button_rect.height - 50 - len(lines) * line_height) / 2
for line in lines:
text_size = rl.measure_text_ex(self._font, line, font_size, 0)
text_x = button_rect.x + (button_rect.width - text_size.x) / 2
rl.draw_text_ex(self._font, line, rl.Vector2(text_x, text_y), font_size, 0, rl.Color(255, 255, 255, 242))
text_y += line_height
feedback_rect = next(button_rect for slot_index, button_rect in self._button_rects if slot_index == self._feedback_slot)
self._draw_feedback(feedback_rect, slot_by_index[self._feedback_slot], elapsed, held)
def _slot_at(self, pos: MousePos) -> int | None:
for slot_index, rect in self._button_rects:
@@ -249,14 +304,37 @@ class FavoriteSlotsOverlay(Widget):
def _handle_mouse_press(self, mouse_pos: MousePos):
self._pressed_slot = self._slot_at(mouse_pos)
if self._pressed_slot is not None:
self._press_pos = mouse_pos
self._max_tap_travel = 0.0
self._feedback_slot = self._pressed_slot
self._feedback_started_at = rl.get_time()
slot = dict(self._visible_slots()).get(self._pressed_slot, {})
key = slot.get("key")
self._feedback_value = not ui_state.params.get_bool(key) if key else None
self._interacting = True
def _handle_mouse_event(self, mouse_event: MouseEvent):
if self._pressed_slot is None or self._press_pos is None:
return
travel = ((mouse_event.pos.x - self._press_pos.x) ** 2 + (mouse_event.pos.y - self._press_pos.y) ** 2) ** 0.5
self._max_tap_travel = max(self._max_tap_travel, travel)
def _handle_mouse_release(self, mouse_pos: MousePos):
released_slot = self._slot_at(mouse_pos)
if self._pressed_slot is not None and released_slot == self._pressed_slot:
toggle_favorite_slot(self._pressed_slot, ui_state.params, ui_state.params_memory)
valid_tap = (
self._pressed_slot is not None and
released_slot == self._pressed_slot and
self._max_tap_travel <= self.MAX_TAP_TRAVEL
)
if valid_tap and toggle_favorite_slot(self._pressed_slot, ui_state.params, ui_state.params_memory):
self._feedback_slot = self._pressed_slot
self._feedback_started_at = rl.get_time()
self._interacting = True
else:
self._feedback_slot = None
self._pressed_slot = None
self._press_pos = None
self._max_tap_travel = 0.0
class MinSteerSpeedBanner(Widget):