import pyray as rl from collections.abc import Callable from abc import ABC, abstractmethod from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.button import gui_button, ButtonStyle from openpilot.system.ui.lib.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT from openpilot.system.ui.lib.widget import Widget LINE_PADDING = 40 ITEM_BASE_HEIGHT = 170 ITEM_PADDING = 20 ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_COLOR = rl.WHITE ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_V_OFFSET = 140 ICON_SIZE = 80 BUTTON_WIDTH = 250 BUTTON_HEIGHT = 100 BUTTON_FONT_SIZE = 35 # Type Aliases for Clarity StrSrc = str | Callable[[], str] | None BoolSrc = bool | Callable[[], bool] def _get_value(value, default=""): if callable(value): return value() return value if value is not None else default class ListItem(Widget, ABC): def __init__(self, title, description: StrSrc=None, enabled: BoolSrc=True, visible: BoolSrc=True, icon=None): super().__init__() self.title = title self._icon = icon self.description = description self.show_desc = False self._enabled_source = enabled self._visible_source = visible self._font = gui_app.font(FontWeight.NORMAL) # Cached properties for performance self._prev_max_width: int = 0 self._wrapped_description: str | None = None self._prev_description: str | None = None self._description_height: float = 0 @property def enabled(self): return _get_value(self._enabled_source, True) @property def is_visible(self): return _get_value(self._visible_source, True) def set_visible(self, visible: bool): self._visible_source = visible def set_enabled(self, enabled: bool): self._enabled_source = enabled def get_desc(self): return _get_value(self.description, "") def set_icon(self, icon: str): self._icon = icon def set_desc(self, description: StrSrc): self.description = description current_description = self.get_desc() if current_description != self._prev_description: self._update_description_cache(self._prev_max_width, current_description) def _update_description_cache(self, max_width: int, current_description: str): """Update the cached description wrapping""" self._prev_max_width = max_width self._prev_description = current_description content_width = max_width - ITEM_PADDING * 2 # Account for icon width if self._icon: content_width -= ICON_SIZE + ITEM_PADDING wrapped_lines = wrap_text(self._font, current_description, ITEM_DESC_FONT_SIZE, content_width) self._wrapped_description = "\n".join(wrapped_lines) self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 def _get_height(self, max_width: int) -> float: if not self.is_visible: return 0 if not self.show_desc: return ITEM_BASE_HEIGHT current_description = self.get_desc() if not current_description: return ITEM_BASE_HEIGHT if current_description != self._prev_description or max_width != self._prev_max_width: self._update_description_cache(max_width, current_description) return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING def _render(self, rect: rl.Rectangle): # Handle click on title/description area for toggling description if self.description and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): mouse_pos = rl.get_mouse_position() text_area_width = rect.width - self.get_action_width() - ITEM_PADDING text_area = rl.Rectangle(rect.x, rect.y, text_area_width, rect.height) if rl.check_collision_point_rec(mouse_pos, text_area): self.show_desc = not self.show_desc # Render title and description x = rect.x + ITEM_PADDING # Draw icon if present if self._icon: icon_texture = gui_app.texture(f"icons/{self._icon}", ICON_SIZE, ICON_SIZE) rl.draw_texture(icon_texture, int(x), int(rect.y + (ITEM_BASE_HEIGHT - ICON_SIZE) // 2), rl.WHITE) x += ICON_SIZE + ITEM_PADDING text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE) title_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 rl.draw_text_ex(self._font, self.title, (x, title_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) # Draw description if visible if self.show_desc and self._wrapped_description: rl.draw_text_ex(self._font, self._wrapped_description, (x, rect.y + ITEM_DESC_V_OFFSET), ITEM_DESC_FONT_SIZE, 0, ITEM_DESC_TEXT_COLOR) # Render action if needed action_width = self.get_action_width() action_rect = rl.Rectangle(rect.x + rect.width - action_width, rect.y, action_width, ITEM_BASE_HEIGHT) self.render_action(action_rect) @abstractmethod def get_action_width(self) -> int: """Return the width needed for the action part (right side)""" @abstractmethod def render_action(self, rect: rl.Rectangle): """Render the action part""" class ToggleItem(ListItem): def __init__(self, title: str, description: StrSrc = None, initial_state: bool=False, callback=None, active_icon=None, **kwargs): super().__init__(title, description, **kwargs) self.toggle = Toggle(initial_state=initial_state) self.callback = callback self._inactive_icon = kwargs.get('icon', None) self._active_icon = active_icon if self._active_icon and initial_state: self.set_icon(self._active_icon) def get_action_width(self) -> int: return TOGGLE_WIDTH def render_action(self, rect: rl.Rectangle): self.toggle.set_enabled(self.enabled) toggle_rect = rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) // 2, TOGGLE_WIDTH, TOGGLE_HEIGHT) if self.toggle.render(toggle_rect): if self._active_icon and self._inactive_icon: self.set_icon(self._active_icon if self.toggle.get_state() else self._inactive_icon) if self.callback: self.callback(self) def set_state(self, state: bool): self.toggle.set_state(state) def get_state(self): return self.toggle.get_state() class ButtonItem(ListItem): def __init__(self, title: str, button_text, description=None, callback=None, **kwargs): super().__init__(title, description, **kwargs) self._button_text_src = button_text self._callback = callback def get_button_text(self): return _get_value(self._button_text_src, "Error") def get_action_width(self) -> int: return BUTTON_WIDTH def render_action(self, rect: rl.Rectangle): button_rect = rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) // 2, BUTTON_WIDTH, BUTTON_HEIGHT) if gui_button(button_rect, self.get_button_text(), border_radius=BUTTON_HEIGHT // 2, font_size=BUTTON_FONT_SIZE, button_style=ButtonStyle.LIST_ACTION, is_enabled=self.enabled): if self._callback: self._callback() class TextItem(ListItem): def __init__(self, title: str, value: str | Callable[[], str], **kwargs): super().__init__(title, **kwargs) self._value_src = value self.color = rl.Color(170, 170, 170, 255) def get_value(self): return _get_value(self._value_src, "") def get_action_width(self) -> int: return int(measure_text_cached(self._font, self.get_value(), ITEM_TEXT_FONT_SIZE).x + ITEM_PADDING) def render_action(self, rect: rl.Rectangle): value = self.get_value() text_size = measure_text_cached(self._font, value, ITEM_TEXT_FONT_SIZE) x = rect.x + (rect.width - text_size.x) // 2 y = rect.y + (rect.height - text_size.y) // 2 rl.draw_text_ex(self._font, value, rl.Vector2(x, y), ITEM_TEXT_FONT_SIZE, 0, self.color) class DualButtonItem(Widget): def __init__(self, left_text: str, right_text: str, left_callback: Callable, right_callback: Callable): super().__init__() self.left_text = left_text self.right_text = right_text self.left_callback = left_callback self.right_callback = right_callback self._button_spacing = 30 self._button_height = 120 def _get_height(self, max_width: int) -> float: return ITEM_BASE_HEIGHT def _render(self, rect: rl.Rectangle): button_width = (rect.width - self._button_spacing) / 2 button_y = rect.y + (rect.height - self._button_height) / 2 left_rect = rl.Rectangle(rect.x, button_y, button_width, self._button_height) right_rect = rl.Rectangle(rect.x + button_width + self._button_spacing, button_y, button_width, self._button_height) left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) if left_clicked and self.left_callback is not None: self.left_callback() if right_clicked and self.right_callback is not None: self.right_callback() class MultipleButtonItem(ListItem): def __init__(self, title: str, description: str, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None, **kwargs): super().__init__(title, description, **kwargs) self.buttons = buttons self.button_width = button_width self.selected_index = selected_index self.callback = callback self._font = gui_app.font(FontWeight.MEDIUM) self._colors = { 'normal': rl.Color(57, 57, 57, 255), # Gray 'hovered': rl.Color(74, 74, 74, 255), # Dark gray 'selected': rl.Color(51, 171, 76, 255), # Green 'disabled': rl.Color(153, 51, 171, 76), # #9933Ab4C - Semi-transparent 'text': rl.Color(228, 228, 228, 255), # Light gray 'text_disabled': rl.Color(51, 228, 228, 228), # #33E4E4E4 - Semi-transparent } def get_action_width(self) -> int: return self.button_width * len(self.buttons) + (len(self.buttons) - 1) * 20 def render_action(self, rect: rl.Rectangle) -> bool: spacing = 20 button_y = rect.y + (rect.height - 100) / 2 clicked = -1 for i, text in enumerate(self.buttons): button_x = rect.x + i * (self.button_width + spacing) button_rect = rl.Rectangle(button_x, button_y, self.button_width, 100) # Check button state mouse_pos = rl.get_mouse_position() is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect) is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) is_selected = i == self.selected_index bg_color = (self._colors['disabled'] if not self.enabled and is_selected else self._colors['selected'] if is_selected else self._colors['hovered'] if is_pressed and self.enabled else self._colors['normal']) text_color = self._colors['text_disabled'] if not self.enabled else self._colors['text'] # Draw button rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) # Draw text text_size = measure_text_cached(self._font, text, 40) text_x = button_x + (self.button_width - text_size.x) / 2 text_y = button_y + (100 - text_size.y) / 2 rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) # Handle click only if enabled if self.enabled and is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): clicked = i if clicked >= 0: self.selected_index = clicked if self.callback: self.callback(clicked) return True return False class ListView(Widget): def __init__(self, items: list[ListItem]): super().__init__() self.items = items self.scroll_panel = GuiScrollPanel() def _render(self, rect: rl.Rectangle): total_height = sum(item._get_height(int(rect.width)) for item in self.items if item.is_visible) # Handle scrolling content_rect = rl.Rectangle(rect.x, rect.y, rect.width, total_height) scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) # Set scissor mode for clipping rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) y = rect.y + scroll_offset.y for i, item in enumerate(self.items): if not item.is_visible: continue item_height = item._get_height(int(rect.width)) # Skip if outside viewport if y + item_height < rect.y or y > rect.y + rect.height: y += item_height continue # Render item item.render(rl.Rectangle(rect.x, y, rect.width, item_height)) # Draw separator line if i < len(self.items) - 1: line_y = int(y + item_height - 1) rl.draw_line(int(rect.x + ITEM_PADDING), line_y, int(rect.x + rect.width - ITEM_PADDING), line_y, rl.GRAY) y += item_height rl.end_scissor_mode()