raylib UI: implement easier to use Scroller (#35606)

* new scroller and widget

start

heck yeah

fix that

clean up

* fuck yeah

* line sep

* fix that

* fix clicking on action

* no custom width

* move all over

* clean up

* more clean up

* rm custom visible too

* more clean up

* lint

* dont use enabled generically yet

* ??
pull/35602/merge
Shane Smiskol 2 days ago committed by GitHub
parent 0a254fbc4e
commit 7b35f64049
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      selfdrive/ui/layouts/settings/developer.py
  2. 10
      selfdrive/ui/layouts/settings/device.py
  3. 7
      selfdrive/ui/layouts/settings/software.py
  4. 7
      selfdrive/ui/layouts/settings/toggles.py
  5. 3
      selfdrive/ui/widgets/offroad_alerts.py
  6. 2
      system/ui/lib/application.py
  7. 302
      system/ui/lib/list_view.py
  8. 13
      system/ui/lib/scroll_panel.py
  9. 74
      system/ui/lib/scroller.py
  10. 37
      system/ui/lib/widget.py
  11. 2
      system/ui/widgets/network.py
  12. 2
      system/ui/widgets/option_dialog.py

@ -1,4 +1,5 @@
from openpilot.system.ui.lib.list_view import ListView, toggle_item from openpilot.system.ui.lib.list_view import toggle_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
@ -49,10 +50,10 @@ class DeveloperLayout(Widget):
), ),
] ]
self._list_widget = ListView(items) self._scroller = Scroller(items, line_separator=True, spacing=0)
def _render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._scroller.render(rect)
def _on_enable_adb(self): pass def _on_enable_adb(self): pass
def _on_joystick_debug_mode(self): pass def _on_joystick_debug_mode(self): pass

@ -7,7 +7,8 @@ from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialo
from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.hardware import TICI from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.list_view import ListView, text_item, button_item, dual_button_item from openpilot.system.ui.lib.list_view import text_item, button_item, dual_button_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget, DialogResult from openpilot.system.ui.lib.widget import Widget, DialogResult
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
@ -37,7 +38,7 @@ class DeviceLayout(Widget):
self._fcc_dialog: HtmlRenderer | None = None self._fcc_dialog: HtmlRenderer | None = None
items = self._initialize_items() items = self._initialize_items()
self._list_widget = ListView(items) self._scroller = Scroller(items, line_separator=True, spacing=0)
def _initialize_items(self): def _initialize_items(self):
dongle_id = self._params.get("DongleId", encoding="utf-8") or "N/A" dongle_id = self._params.get("DongleId", encoding="utf-8") or "N/A"
@ -49,15 +50,16 @@ class DeviceLayout(Widget):
button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device), button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device),
button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad), button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad),
button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt), button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt),
button_item("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI), regulatory_btn := button_item("Regulatory", "VIEW", callback=self._on_regulatory),
button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide),
button_item("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad), button_item("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad),
dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt), dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt),
] ]
regulatory_btn.set_visible(TICI)
return items return items
def _render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._scroller.render(rect)
def _show_language_selection(self): def _show_language_selection(self):
try: try:

@ -1,6 +1,7 @@
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.list_view import ListView, button_item, text_item from openpilot.system.ui.lib.list_view import button_item, text_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget, DialogResult from openpilot.system.ui.lib.widget import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog
@ -11,7 +12,7 @@ class SoftwareLayout(Widget):
self._params = Params() self._params = Params()
items = self._init_items() items = self._init_items()
self._list_widget = ListView(items) self._scroller = Scroller(items, line_separator=True, spacing=0)
def _init_items(self): def _init_items(self):
items = [ items = [
@ -24,7 +25,7 @@ class SoftwareLayout(Widget):
return items return items
def _render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._scroller.render(rect)
def _on_download_update(self): pass def _on_download_update(self): pass
def _on_install_update(self): pass def _on_install_update(self): pass

@ -1,4 +1,5 @@
from openpilot.system.ui.lib.list_view import ListView, multiple_button_item, toggle_item from openpilot.system.ui.lib.list_view import multiple_button_item, toggle_item
from openpilot.system.ui.lib.scroller import Scroller
from openpilot.system.ui.lib.widget import Widget from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params from openpilot.common.params import Params
@ -78,10 +79,10 @@ class TogglesLayout(Widget):
), ),
] ]
self._list_widget = ListView(items) self._scroller = Scroller(items, line_separator=True, spacing=0)
def _render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._scroller.render(rect)
def _set_longitudinal_personality(self, button_index: int): def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", str(button_index)) self._params.put("LongitudinalPersonality", str(button_index))

@ -70,8 +70,7 @@ class AbstractAlert(Widget, ABC):
pass pass
def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool: def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool:
# TODO: fix scroll_panel.is_click_valid() if not mouse_clicked or not self.scroll_panel.is_touch_valid():
if not mouse_clicked:
return False return False
if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect): if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect):

@ -225,7 +225,7 @@ class GuiApplication:
for layout in KEYBOARD_LAYOUTS.values(): for layout in KEYBOARD_LAYOUTS.values():
all_chars.update(key for row in layout for key in row) all_chars.update(key for row in layout for key in row)
all_chars = "".join(all_chars) all_chars = "".join(all_chars)
all_chars += "–✓" all_chars += "–✓°"
codepoint_count = rl.ffi.new("int *", 1) codepoint_count = rl.ffi.new("int *", 1)
codepoints = rl.load_codepoints(all_chars, codepoint_count) codepoints = rl.load_codepoints(all_chars, codepoint_count)

@ -1,9 +1,7 @@
import os import os
import pyray as rl import pyray as rl
from dataclasses import dataclass
from collections.abc import Callable from collections.abc import Callable
from abc import ABC from abc import ABC
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight 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.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.wrap_text import wrap_text
@ -11,11 +9,9 @@ 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.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.widget import Widget from openpilot.system.ui.lib.widget import Widget
ITEM_BASE_WIDTH = 600
ITEM_BASE_HEIGHT = 170 ITEM_BASE_HEIGHT = 170
LINE_PADDING = 40
LINE_COLOR = rl.GRAY
ITEM_PADDING = 20 ITEM_PADDING = 20
ITEM_SPACING = 80
ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE ITEM_TEXT_COLOR = rl.WHITE
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
@ -40,18 +36,15 @@ def _resolve_value(value, default=""):
# Abstract base class for right-side items # Abstract base class for right-side items
class ItemAction(Widget, ABC): class ItemAction(Widget, ABC):
def __init__(self, width: int = 100, enabled: bool | Callable[[], bool] = True): def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
super().__init__() super().__init__()
self.width = width self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled self._enabled_source = enabled
@property @property
def enabled(self): def enabled(self):
return _resolve_value(self._enabled_source, False) return _resolve_value(self._enabled_source, False)
def get_width(self) -> int:
return self.width
class ToggleAction(ItemAction): class ToggleAction(ItemAction):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True):
@ -61,7 +54,7 @@ class ToggleAction(ItemAction):
def _render(self, rect: rl.Rectangle) -> bool: def _render(self, rect: rl.Rectangle) -> bool:
self.toggle.set_enabled(self.enabled) self.toggle.set_enabled(self.enabled)
self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)) self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT))
return False return False
def set_state(self, state: bool): def set_state(self, state: bool):
@ -160,12 +153,12 @@ class MultipleButtonAction(ItemAction):
def _render(self, rect: rl.Rectangle) -> bool: def _render(self, rect: rl.Rectangle) -> bool:
spacing = 20 spacing = 20
button_y = rect.y + (rect.height - 100) / 2 button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
clicked = -1 clicked = -1
for i, text in enumerate(self.buttons): for i, text in enumerate(self.buttons):
button_x = rect.x + i * (self.button_width + spacing) button_x = rect.x + i * (self.button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, self.button_width, 100) button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
# Check button state # Check button state
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
@ -187,7 +180,7 @@ class MultipleButtonAction(ItemAction):
# Draw text # Draw text
text_size = measure_text_cached(self._font, text, 40) text_size = measure_text_cached(self._font, text, 40)
text_x = button_x + (self.button_width - text_size.x) / 2 text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (100 - text_size.y) / 2 text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, rl.Color(228, 228, 228, 255)) rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, rl.Color(228, 228, 228, 255))
# Handle click # Handle click
@ -202,26 +195,92 @@ class MultipleButtonAction(ItemAction):
return False return False
@dataclass class ListItem(Widget):
class ListItem: def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
title: str description_visible: bool = False, callback: Callable | None = None,
icon: str | None = None action_item: ItemAction | None = None):
description: str | Callable[[], str] | None = None super().__init__()
description_visible: bool = False self.title = title
rect: "rl.Rectangle" = rl.Rectangle(0, 0, 0, 0) self.icon = icon
callback: Callable | None = None self.description = description
action_item: ItemAction | None = None self.description_visible = description_visible
visible: bool | Callable[[], bool] = True self.callback = callback
self.action_item = action_item
# Cached properties for performance self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
_prev_max_width: int = 0 self._font = gui_app.font(FontWeight.NORMAL)
_wrapped_description: str | None = None
_prev_description: str | None = None
_description_height: float = 0
@property # Cached properties for performance
def is_visible(self) -> bool: self._prev_max_width: int = 0
return bool(_resolve_value(self.visible, True)) self._wrapped_description: str | None = None
self._prev_description: str | None = None
self._description_height: float = 0
def set_parent_rect(self, parent_rect: rl.Rectangle):
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _handle_mouse_release(self, mouse_pos: rl.Vector2):
if not self.is_visible:
return
# Check not in action rect
if self.action_item:
action_rect = self.get_right_item_rect(self._rect)
if rl.check_collision_point_rec(mouse_pos, action_rect):
# Click was on right item, don't toggle description
return
if self.description:
self.description_visible = not self.description_visible
content_width = self.get_content_width(int(self._rect.width - ITEM_PADDING * 2))
self._rect.height = self.get_item_height(self._font, content_width)
def _render(self, _):
if not self.is_visible:
return
# Don't draw items that are not in parent's viewport
if ((self._rect.y + self.rect.height) <= self._parent_rect.y or
self._rect.y >= (self._parent_rect.y + self._parent_rect.height)):
return
content_x = self._rect.x + ITEM_PADDING
text_x = content_x
# Only draw title and icon for items that have them
if self.title:
# Draw icon if present
if self.icon:
icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(icon_texture, int(content_x), int(self._rect.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, self.title, ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible
current_description = self.get_description()
if self.description_visible and current_description and self._wrapped_description:
rl.draw_text_ex(
self._font,
self._wrapped_description,
rl.Vector2(text_x, self._rect.y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
)
# Draw right item if present
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
right_rect.y = self._rect.y
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if self.callback:
self.callback()
def get_description(self): def get_description(self):
return _resolve_value(self.description, None) return _resolve_value(self.description, None)
@ -247,15 +306,15 @@ class ListItem:
return ITEM_BASE_HEIGHT return ITEM_BASE_HEIGHT
def get_content_width(self, total_width: int) -> int: def get_content_width(self, total_width: int) -> int:
if self.action_item and self.action_item.get_width() > 0: if self.action_item and self.action_item.rect.width > 0:
return total_width - self.action_item.get_width() - RIGHT_ITEM_PADDING return total_width - int(self.action_item.rect.width) - RIGHT_ITEM_PADDING
return total_width return total_width
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item: if not self.action_item:
return rl.Rectangle(0, 0, 0, 0) return rl.Rectangle(0, 0, 0, 0)
right_width = self.action_item.get_width() right_width = self.action_item.rect.width
if right_width == 0: # Full width action (like DualButtonAction) if right_width == 0: # Full width action (like DualButtonAction)
return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y,
item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT) item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT)
@ -265,186 +324,33 @@ class ListItem:
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
class ListView(Widget):
def __init__(self, items: list[ListItem]):
super().__init__()
self._items = items
self.scroll_panel = GuiScrollPanel()
self._font = gui_app.font(FontWeight.NORMAL)
self._hovered_item = -1
self._total_height = 0
def _render(self, rect: rl.Rectangle):
self._update_layout_rects()
# Update layout and handle scrolling
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height)
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect)
# Handle mouse interaction
if self.scroll_panel.is_click_valid():
self._handle_mouse_interaction(rect, scroll_offset)
# Set scissor mode for clipping
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
for i, item in enumerate(self._items):
if not item.is_visible:
continue
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)
# Draw separator line
next_visible_item = self._get_next_visible_item(i)
if next_visible_item is not None:
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 _get_next_visible_item(self, current_index: int) -> int | None:
for i in range(current_index + 1, len(self._items)):
if self._items[i].is_visible:
return i
return None
def _update_layout_rects(self):
current_y = 0.0
for item in self._items:
if not item.is_visible:
item.rect = rl.Rectangle(self._rect.x, self._rect.y + current_y, self._rect.width, 0)
continue
content_width = item.get_content_width(int(self._rect.width - ITEM_PADDING * 2))
item_height = item.get_item_height(self._font, content_width)
item.rect = rl.Rectangle(self._rect.x, self._rect.y + current_y, self._rect.width, item_height)
current_y += item_height
self._total_height = current_y # total height of all items
def _render_item(self, item: ListItem, y: int):
content_x = item.rect.x + ITEM_PADDING
text_x = content_x
# Only draw title and icon for items that have them
if item.title:
# 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(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, 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,
item._wrapped_description,
rl.Vector2(text_x, y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
)
# Draw right item if present
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 _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
mouse_pos = rl.get_mouse_position()
self._hovered_item = -1
if not rl.check_collision_point_rec(mouse_pos, rect):
return
content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y
for i, item in enumerate(self._items):
if not item.is_visible:
continue
if item.rect:
# Check if mouse is within this item's bounds in content space
if (
mouse_pos.x >= rect.x
and mouse_pos.x <= rect.x + rect.width
and content_mouse_y >= item.rect.y
and content_mouse_y <= item.rect.y + item.rect.height
):
item_screen_y = item.rect.y + scroll_offset.y
if item_screen_y < rect.height and item_screen_y + item.rect.height > 0:
self._hovered_item = i
break
# Handle click on main item (not right item)
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0:
item = self._items[self._hovered_item]
# Check if click was on right item area
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 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
# Factory functions # Factory functions
def simple_item(title: str, callback: Callable | None = None, visible: bool | Callable[[], bool] = True) -> ListItem: def simple_item(title: str, callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback, visible=visible) return ListItem(title=title, callback=callback)
def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False, 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, callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
visible: bool | Callable[[], bool] = True) -> ListItem:
action = ToggleAction(initial_state=initial_state, enabled=enabled) action = ToggleAction(initial_state=initial_state, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback, visible=visible) return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback)
def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, 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, callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
visible: bool | Callable[[], bool] = True) -> ListItem:
action = ButtonAction(text=button_text, enabled=enabled) action = ButtonAction(text=button_text, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback, visible=visible) return ListItem(title=title, description=description, action_item=action, callback=callback)
def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None, def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
visible: bool | Callable[[], bool] = True) -> ListItem:
action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled) action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback, visible=visible) return ListItem(title=title, description=description, action_item=action, callback=callback)
def dual_button_item(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None, def dual_button_item(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None,
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True, description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
visible: bool | Callable[[], bool] = True) -> ListItem:
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled) action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
return ListItem(title="", description=description, action_item=action, visible=visible) return ListItem(title="", description=description, action_item=action)
def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int, def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int,

@ -58,13 +58,15 @@ class GuiScrollPanel:
if mouse_pos.x >= scrollbar_x: if mouse_pos.x >= scrollbar_x:
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR self._scroll_state = ScrollState.DRAGGING_SCROLLBAR
# TODO: hacky
# when clicking while moving, go straight into dragging
self._is_dragging = abs(self._velocity_y) > MIN_VELOCITY
self._last_mouse_y = mouse_pos.y self._last_mouse_y = mouse_pos.y
self._start_mouse_y = mouse_pos.y self._start_mouse_y = mouse_pos.y
self._last_drag_time = current_time self._last_drag_time = current_time
self._velocity_history.clear() self._velocity_history.clear()
self._velocity_y = 0.0 self._velocity_y = 0.0
self._bounce_offset = 0.0 self._bounce_offset = 0.0
self._is_dragging = False
# Handle active dragging # Handle active dragging
if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
@ -167,13 +169,8 @@ class GuiScrollPanel:
return self._offset return self._offset
def is_click_valid(self) -> bool: def is_touch_valid(self):
# Check if this is a click rather than a drag return not self._is_dragging
return (
self._scroll_state == ScrollState.IDLE
and not self._is_dragging
and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
)
def get_normalized_scroll_position(self) -> float: def get_normalized_scroll_position(self) -> float:
"""Returns the current scroll position as a value from 0.0 to 1.0""" """Returns the current scroll position as a value from 0.0 to 1.0"""

@ -0,0 +1,74 @@
import pyray as rl
from openpilot.system.ui.lib.widget import Widget
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
ITEM_SPACING = 40
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING * 2, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
self._line_separator = line_separator
self._pad_end = pad_end
self.scroll_panel = GuiScrollPanel()
for item in items:
self.add_widget(item)
def add_widget(self, item: Widget) -> None:
if self._line_separator and len(self._items) > 0:
self._items.append(LineSeparator())
self._items.append(item)
item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
def _render(self, _):
# TODO: don't draw items that are not in the viewport
visible_items = [item for item in self._items if item.is_visible]
content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
if not self._pad_end:
content_height -= self._spacing
scroll = self.scroll_panel.handle_scroll(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
cur_height = 0
for idx, item in enumerate(visible_items):
if not item.is_visible:
continue
# Nicely lay out items vertically
x = self._rect.x
y = self._rect.y + cur_height + self._spacing * (idx != 0)
cur_height += item.rect.height + self._spacing * (idx != 0)
# Consider scroll
x += scroll.x
y += scroll.y
# Update item state
item.set_position(x, y)
item.set_parent_rect(self._rect)
item.render()
rl.end_scissor_mode()

@ -13,21 +13,45 @@ class DialogResult(IntEnum):
class Widget(abc.ABC): class Widget(abc.ABC):
def __init__(self): def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._is_pressed = False self._is_pressed = False
self._is_visible: bool | Callable[[], bool] = True self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
"""Set a callback to determine if the widget can be clicked."""
self._touch_valid_callback = touch_callback
def _touch_valid(self) -> bool:
"""Check if the widget can be touched."""
return self._touch_valid_callback() if self._touch_valid_callback else True
@property @property
def is_visible(self) -> bool: def is_visible(self) -> bool:
return self._is_visible() if callable(self._is_visible) else self._is_visible return self._is_visible() if callable(self._is_visible) else self._is_visible
@property
def rect(self) -> rl.Rectangle:
return self._rect
def set_visible(self, visible: bool | Callable[[], bool]) -> None: def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible self._is_visible = visible
def set_rect(self, rect: rl.Rectangle) -> None: def set_rect(self, rect: rl.Rectangle) -> None:
prev_rect = self._rect changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
self._rect = rect self._rect = rect
if (rect.x != prev_rect.x or rect.y != prev_rect.y or if changed:
rect.width != prev_rect.width or rect.height != prev_rect.height): self._update_layout_rects()
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
"""Can be used like size hint in QT"""
self._parent_rect = parent_rect
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect.x, self._rect.y = x, y
if changed:
self._update_layout_rects() self._update_layout_rects()
def render(self, rect: rl.Rectangle = None) -> bool | int | None: def render(self, rect: rl.Rectangle = None) -> bool | int | None:
@ -43,11 +67,14 @@ class Widget(abc.ABC):
# Keep track of whether mouse down started within the widget's rectangle # Keep track of whether mouse down started within the widget's rectangle
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._touch_valid():
if rl.check_collision_point_rec(mouse_pos, self._rect): if rl.check_collision_point_rec(mouse_pos, self._rect):
self._is_pressed = True self._is_pressed = True
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): elif not self._touch_valid():
self._is_pressed = False
elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
if self._is_pressed and rl.check_collision_point_rec(mouse_pos, self._rect): if self._is_pressed and rl.check_collision_point_rec(mouse_pos, self._rect):
self._handle_mouse_release(mouse_pos) self._handle_mouse_release(mouse_pos)
self._is_pressed = False self._is_pressed = False

@ -117,7 +117,7 @@ class WifiManagerUI(Widget):
def _draw_network_list(self, rect: rl.Rectangle): def _draw_network_list(self, rect: rl.Rectangle):
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT) content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT)
offset = self.scroll_panel.handle_scroll(rect, content_rect) offset = self.scroll_panel.handle_scroll(rect, content_rect)
clicked = self.scroll_panel.is_click_valid() clicked = self.scroll_panel.is_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
for i, network in enumerate(self._networks): for i, network in enumerate(self._networks):

@ -42,7 +42,7 @@ class MultiOptionDialog(Widget):
# Scroll and render options # Scroll and render options
offset = self.scroll.handle_scroll(view_rect, list_content_rect) offset = self.scroll.handle_scroll(view_rect, list_content_rect)
valid_click = self.scroll.is_click_valid() valid_click = self.scroll.is_touch_valid() and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
rl.begin_scissor_mode(int(view_rect.x), int(options_y), int(view_rect.width), int(options_h)) rl.begin_scissor_mode(int(view_rect.x), int(options_y), int(view_rect.width), int(options_h))
for i, option in enumerate(self.options): for i, option in enumerate(self.options):

Loading…
Cancel
Save