From 191d0d429e4732b5d4470ed82bff637d6b5a768e Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Mon, 9 Jun 2025 10:31:55 +0800 Subject: [PATCH] ui: enhanced ListView with improved actions, dynamic content, and better UX (#35485) improve list view --- system/ui/lib/button.py | 19 ++- system/ui/lib/list_view.py | 321 +++++++++++++++---------------------- 2 files changed, 142 insertions(+), 198 deletions(-) diff --git a/system/ui/lib/button.py b/system/ui/lib/button.py index 123bb7b2de..df4f93fc85 100644 --- a/system/ui/lib/button.py +++ b/system/ui/lib/button.py @@ -10,6 +10,7 @@ class ButtonStyle(IntEnum): DANGER = 2 # For critical actions, like reboot or delete TRANSPARENT = 3 # For buttons with transparent background and border ACTION = 4 + LIST_ACTION = 5 # For list items with action buttons class TextAlignment(IntEnum): @@ -20,10 +21,18 @@ class TextAlignment(IntEnum): ICON_PADDING = 15 DEFAULT_BUTTON_FONT_SIZE = 60 -BUTTON_ENABLED_TEXT_COLOR = rl.Color(228, 228, 228, 255) BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51) ACTION_BUTTON_FONT_SIZE = 48 -ACTION_BUTTON_TEXT_COLOR = rl.Color(0, 0, 0, 255) + + +BUTTON_TEXT_COLOR = { + ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255), + ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255), + ButtonStyle.DANGER: rl.Color(228, 228, 228, 255), + ButtonStyle.TRANSPARENT: rl.BLACK, + ButtonStyle.ACTION: rl.Color(0, 0, 0, 255), + ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255), +} BUTTON_BACKGROUND_COLORS = { ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255), @@ -31,6 +40,7 @@ BUTTON_BACKGROUND_COLORS = { ButtonStyle.DANGER: rl.Color(255, 36, 36, 255), ButtonStyle.TRANSPARENT: rl.BLACK, ButtonStyle.ACTION: rl.Color(189, 189, 189, 255), + ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255), } BUTTON_PRESSED_BACKGROUND_COLORS = { @@ -39,6 +49,7 @@ BUTTON_PRESSED_BACKGROUND_COLORS = { ButtonStyle.DANGER: rl.Color(255, 36, 36, 255), ButtonStyle.TRANSPARENT: rl.BLACK, ButtonStyle.ACTION: rl.Color(130, 130, 130, 255), + ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74), } _pressed_buttons: set[str] = set() # Track mouse press state globally @@ -132,7 +143,7 @@ def gui_button( # Draw the button text if any if text: - text_color = ACTION_BUTTON_TEXT_COLOR if button_style == ButtonStyle.ACTION else BUTTON_ENABLED_TEXT_COLOR if is_enabled else BUTTON_DISABLED_TEXT_COLOR - rl.draw_text_ex(font, text, text_pos, font_size, 0, text_color) + color = BUTTON_TEXT_COLOR[button_style] if is_enabled else BUTTON_DISABLED_TEXT_COLOR + rl.draw_text_ex(font, text, text_pos, font_size, 0, color) return result diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py index 94e0cfeb80..d68e71a682 100644 --- a/system/ui/lib/list_view.py +++ b/system/ui/lib/list_view.py @@ -2,24 +2,25 @@ import os import pyray as rl from dataclasses import dataclass from collections.abc import Callable -from abc import ABC, abstractmethod +from abc import ABC from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app, FontWeight, Widget 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 +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 + +ITEM_BASE_HEIGHT = 170 LINE_PADDING = 40 LINE_COLOR = rl.GRAY ITEM_PADDING = 20 ITEM_SPACING = 80 -ITEM_BASE_HEIGHT = 170 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 = 130 +ITEM_DESC_V_OFFSET = 140 RIGHT_ITEM_PADDING = 20 ICON_SIZE = 80 BUTTON_WIDTH = 250 @@ -28,35 +29,38 @@ BUTTON_BORDER_RADIUS = 50 BUTTON_FONT_SIZE = 35 BUTTON_FONT_WEIGHT = FontWeight.MEDIUM +TEXT_PADDING = 20 + +def _resolve_value(value, default=""): + return value() if callable(value) else (value or default) + # Abstract base class for right-side items -class RightItem(Widget, ABC): - def __init__(self, width: int = 100): +class ItemAction(Widget, ABC): + def __init__(self, width: int = 100, enabled: bool | Callable[[], bool] = True): super().__init__() self.width = width - self.enabled = True + self._enabled_source = enabled + + @property + def enabled(self): + return _resolve_value(self._enabled_source, False) - @abstractmethod def get_width(self) -> int: - pass + return self.width -class ToggleRightItem(RightItem): - def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH): - super().__init__(width) +class ToggleAction(ItemAction): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): + super().__init__(width, enabled) self.toggle = Toggle(initial_state=initial_state) self.state = initial_state - self.enabled = True def _render(self, rect: rl.Rectangle) -> bool: - if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)): - self.state = not self.state - return True + self.toggle.set_enabled(self.enabled) + self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)) return False - def get_width(self) -> int: - return self.width - def set_state(self, state: bool): self.state = state self.toggle.set_state(state) @@ -64,103 +68,101 @@ class ToggleRightItem(RightItem): def get_state(self) -> bool: return self.state - def set_enabled(self, enabled: bool): - self.enabled = enabled +class ButtonAction(ItemAction): + def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): + super().__init__(width, enabled) + self._text_source = text -class ButtonRightItem(RightItem): - def __init__(self, text: str, width: int = BUTTON_WIDTH): - super().__init__(width) - self.text = text - self.enabled = True + @property + def text(self): + return _resolve_value(self._text_source, "Error") def _render(self, rect: rl.Rectangle) -> bool: - return ( - gui_button( - rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT), - self.text, - border_radius=BUTTON_BORDER_RADIUS, - font_weight=BUTTON_FONT_WEIGHT, - font_size=BUTTON_FONT_SIZE, - is_enabled=self.enabled, - ) - == 1 - ) - - def get_width(self) -> int: - return self.width - - def set_enabled(self, enabled: bool): - self.enabled = enabled - - -class TextRightItem(RightItem): - def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE): - self.text = text + return gui_button( + rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT), + self.text, + border_radius=BUTTON_BORDER_RADIUS, + font_weight=BUTTON_FONT_WEIGHT, + font_size=BUTTON_FONT_SIZE, + button_style=ButtonStyle.LIST_ACTION, + is_enabled=self.enabled, + ) == 1 + + +class TextAction(ItemAction): + def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True): + self._text_source = text self.color = color - self.font_size = font_size - font = gui_app.font(FontWeight.NORMAL) - text_width = measure_text_cached(font, text, font_size).x - super().__init__(int(text_width + 20)) + self._font = gui_app.font(FontWeight.NORMAL) + initial_text = _resolve_value(text, "") + text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x + super().__init__(int(text_width + TEXT_PADDING), enabled) + + @property + def text(self): + return _resolve_value(self._text_source, "Error") def _render(self, rect: rl.Rectangle) -> bool: - font = gui_app.font(FontWeight.NORMAL) - text_size = measure_text_cached(font, self.text, self.font_size) + current_text = self.text + text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE) - # Center the text in the allocated rectangle text_x = rect.x + (rect.width - text_size.x) / 2 text_y = rect.y + (rect.height - text_size.y) / 2 - - rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color) + rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color) return False def get_width(self) -> int: - return self.width - - def set_text(self, text: str): - self.text = text - font = gui_app.font(FontWeight.NORMAL) - text_width = measure_text_cached(font, text, self.font_size).x - self.width = int(text_width + 20) + text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x + return int(text_width + TEXT_PADDING) @dataclass class ListItem: title: str icon: str | None = None - description: str | None = None + description: str | Callable[[], str] | None = None description_visible: bool = False - rect: "rl.Rectangle | None" = None + rect: "rl.Rectangle" = rl.Rectangle(0, 0, 0, 0) callback: Callable | None = None - right_item: RightItem | None = None + action_item: ItemAction | None = None # Cached properties for performance + _prev_max_width: int = 0 _wrapped_description: str | None = None + _prev_description: str | None = None _description_height: float = 0 - def get_right_item(self) -> RightItem | None: - return self.right_item + def actiion(self) -> ItemAction | None: + return self.action_item + + def get_description(self): + return _resolve_value(self.description, None) def get_item_height(self, font: rl.Font, max_width: int) -> float: - if self.description_visible and self.description: - if not self._wrapped_description: - wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width) + current_description = self.get_description() + if self.description_visible and current_description: + if not self._wrapped_description or current_description != self._prev_description or max_width != self._prev_max_width: + self._prev_max_width = max_width + self._prev_description = current_description + + wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width) self._wrapped_description = "\n".join(wrapped_lines) - self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding - return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING + self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 # Line height + padding + return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING return ITEM_BASE_HEIGHT def get_content_width(self, total_width: int) -> int: - if self.right_item: - return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING + if self.action_item: + return total_width - self.action_item.get_width() - RIGHT_ITEM_PADDING return total_width def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: - if not self.right_item: + if not self.action_item: return rl.Rectangle(0, 0, 0, 0) - right_width = self.right_item.get_width() + right_width = self.action_item.get_width() right_x = item_rect.x + item_rect.width - right_width right_y = item_rect.y return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) @@ -170,28 +172,15 @@ class ListView(Widget): def __init__(self, items: list[ListItem]): super().__init__() self._items: list[ListItem] = items - self._last_dim: tuple[float, float] = (0, 0) self.scroll_panel = GuiScrollPanel() - - self._font_normal = gui_app.font(FontWeight.NORMAL) - - # Interaction state + self._font = gui_app.font(FontWeight.NORMAL) self._hovered_item: int = -1 - self._last_mouse_pos = rl.Vector2(0, 0) - - self._total_height: float = 0 - self._visible_range = (0, 0) - - def invalid_height_cache(self): - self._last_dim = (0, 0) def _render(self, rect: rl.Rectangle): - if self._last_dim != (rect.width, rect.height): - self._update_item_rects(rect) - self._last_dim = (rect.width, rect.height) + total_height = self._update_item_rects(rect) # Update layout and handle scrolling - content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height) + content_rect = rl.Rectangle(rect.x, rect.y, rect.width, total_height) scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) # Handle mouse interaction @@ -201,112 +190,62 @@ class ListView(Widget): # Set scissor mode for clipping rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) - # Calculate visible range for performance - self._calculate_visible_range(rect, -scroll_offset.y) + for i, item in enumerate(self._items): + y = int(item.rect.y + scroll_offset.y) + if y + item.rect.height <= rect.y or y >= rect.y + rect.height: + continue + + self._render_item(item, y) - # Render only visible items - for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))): - item = self._items[i] - if item.rect: - adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) - self._render_item(item, adjusted_rect, i) - - if i != len(self._items) - 1: - rl.draw_line_ex( - rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1), - rl.Vector2( - adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1 - ), - 1.0, - LINE_COLOR, - ) + if i < len(self._items) - 1: + line_y = int(y + item.rect.height - 1) + rl.draw_line(int(item.rect.x) + LINE_PADDING, line_y, int(item.rect.x + item.rect.width) - LINE_PADDING * 2, line_y, LINE_COLOR) rl.end_scissor_mode() - def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int): - content_x = rect.x + ITEM_PADDING + def _render_item(self, item: ListItem, y: int): + content_x = item.rect.x + ITEM_PADDING text_x = content_x - # Calculate available width for main content - content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2)) - # Draw icon if present if item.icon: icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE) - rl.draw_texture( - icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE - ) + rl.draw_texture(icon_texture, int(content_x), int(y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE) text_x += ICON_SIZE + ITEM_PADDING # Draw main text - text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE) - item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 - rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) - - # Draw description if visible (adjust width for right item) - if item.description_visible and item._wrapped_description: - desc_y = rect.y + ITEM_DESC_V_OFFSET - desc_max_width = int(content_width - (text_x - content_x)) - - # Re-wrap description if needed due to right item - if (item.right_item and item.description) and not item._wrapped_description: - wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width) - item._wrapped_description = "\n".join(wrapped_lines) + text_size = measure_text_cached(self._font, item.title, ITEM_TEXT_FONT_SIZE) + item_y = y + (ITEM_BASE_HEIGHT - text_size.y) // 2 + rl.draw_text_ex(self._font, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) + # Draw description if visible + current_description = item.get_description() + if item.description_visible and current_description and item._wrapped_description: rl.draw_text_ex( - self._font_normal, + self._font, item._wrapped_description, - rl.Vector2(text_x, desc_y), + (text_x, y + ITEM_DESC_V_OFFSET), ITEM_DESC_FONT_SIZE, 0, ITEM_DESC_TEXT_COLOR, ) # Draw right item if present - if item.right_item: - right_rect = item.get_right_item_rect(rect) - # Adjust for scroll offset - right_rect.y = right_rect.y - if item.right_item.render(right_rect): + if item.action_item: + right_rect = item.get_right_item_rect(item.rect) + right_rect.y = y + if item.action_item.render(right_rect) and item.action_item.enabled: # Right item was clicked/activated if item.callback: item.callback() - def _update_item_rects(self, container_rect: rl.Rectangle) -> None: - current_y: float = 0.0 - self._total_height = 0 - + def _update_item_rects(self, container_rect: rl.Rectangle) -> float: + current_y = 0.0 for item in self._items: content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) - item_height = item.get_item_height(self._font_normal, content_width) + item_height = item.get_item_height(self._font, content_width) item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) current_y += item_height - self._total_height += item_height - - def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float): - if not self._items: - self._visible_range = (0, 0) - return - - visible_top = scroll_offset - visible_bottom = scroll_offset + rect.height - - start_idx = 0 - end_idx = len(self._items) - - # Find first visible item - for i, item in enumerate(self._items): - if item.rect and item.rect.y + item.rect.height >= visible_top: - start_idx = max(0, i - 1) - break - - # Find last visible item - for i in range(start_idx, len(self._items)): - item = self._items[i] - if item.rect and item.rect.y > visible_bottom: - end_idx = min(len(self._items), i + 2) - break - - self._visible_range = (start_idx, end_idx) + return current_y # total height of all items def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): mouse_pos = rl.get_mouse_position() @@ -336,41 +275,35 @@ class ListView(Widget): item = self._items[self._hovered_item] # Check if click was on right item area - if item.right_item and item.rect: + if item.action_item and item.rect: + # Use the same coordinate system as in _render_item adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) right_rect = item.get_right_item_rect(adjusted_rect) + if rl.check_collision_point_rec(mouse_pos, right_rect): - # Click was handled by right item, don't process main item click + # Click was on right item, don't toggle description return # Toggle description visibility if item has description if item.description: item.description_visible = not item.description_visible - # Force layout update when description visibility changes - self._last_dim = (0, 0) - - # Call item callback - if item.callback: - item.callback() # Factory functions def simple_item(title: str, callback: Callable | None = None) -> ListItem: return ListItem(title=title, callback=callback) +def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False, + callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem: + action = ToggleAction(initial_state=initial_state, enabled=enabled) + return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback) -def toggle_item( - title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "" -) -> ListItem: - toggle = ToggleRightItem(initial_state=initial_state) - return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback) - - -def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem: - button = ButtonRightItem(text=button_text) - return ListItem(title=title, description=description, right_item=button, callback=callback) - +def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: + action = ButtonAction(text=button_text, enabled=enabled) + return ListItem(title=title, description=description, action_item=action, callback=callback) -def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem: - text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255)) - return ListItem(title=title, description=description, right_item=text_item, callback=callback) +def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None, + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: + action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled) + return ListItem(title=title, description=description, action_item=action, callback=callback)