ui: enhanced ListView with improved actions, dynamic content, and better UX (#35485)

improve list view
pull/35502/head
Dean Lee 7 days ago committed by GitHub
parent af48d23a68
commit 191d0d429e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 19
      system/ui/lib/button.py
  2. 305
      system/ui/lib/list_view.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

@ -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(
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
)
) == 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
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
# 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,
)
self._render_item(item, y)
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)

Loading…
Cancel
Save