openpilot is an open source driver assistance system. openpilot performs the functions of Automated Lane Centering and Adaptive Cruise Control for over 200 supported car makes and models.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

354 lines
13 KiB

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()