diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index de1bcaac4b..ce6e9e4af6 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.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.common.params import Params 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): - self._list_widget.render(rect) + self._scroller.render(rect) def _on_enable_adb(self): pass def _on_joystick_debug_mode(self): pass diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 79c90dc009..69b7822ba9 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -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.system.hardware import TICI 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.selfdrive.ui.widgets.pairing_dialog import PairingDialog from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog @@ -37,7 +38,7 @@ class DeviceLayout(Widget): self._fcc_dialog: HtmlRenderer | None = None items = self._initialize_items() - self._list_widget = ListView(items) + self._scroller = Scroller(items, line_separator=True, spacing=0) def _initialize_items(self): 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("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("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("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), ] + regulatory_btn.set_visible(TICI) return items def _render(self, rect): - self._list_widget.render(rect) + self._scroller.render(rect) def _show_language_selection(self): try: diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 39b883984e..b97e306ff9 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -1,6 +1,7 @@ from openpilot.common.params import Params 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.widgets.confirm_dialog import confirm_dialog @@ -11,7 +12,7 @@ class SoftwareLayout(Widget): self._params = Params() items = self._init_items() - self._list_widget = ListView(items) + self._scroller = Scroller(items, line_separator=True, spacing=0) def _init_items(self): items = [ @@ -24,7 +25,7 @@ class SoftwareLayout(Widget): return items def _render(self, rect): - self._list_widget.render(rect) + self._scroller.render(rect) def _on_download_update(self): pass def _on_install_update(self): pass diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 2c18e44eff..5c17082769 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -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.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): - self._list_widget.render(rect) + self._scroller.render(rect) def _set_longitudinal_personality(self, button_index: int): self._params.put("LongitudinalPersonality", str(button_index)) diff --git a/selfdrive/ui/widgets/offroad_alerts.py b/selfdrive/ui/widgets/offroad_alerts.py index 1a156f923e..aba07139db 100644 --- a/selfdrive/ui/widgets/offroad_alerts.py +++ b/selfdrive/ui/widgets/offroad_alerts.py @@ -70,8 +70,7 @@ class AbstractAlert(Widget, ABC): pass def handle_input(self, mouse_pos: rl.Vector2, mouse_clicked: bool) -> bool: - # TODO: fix scroll_panel.is_click_valid() - if not mouse_clicked: + if not mouse_clicked or not self.scroll_panel.is_touch_valid(): return False if rl.check_collision_point_rec(mouse_pos, self.dismiss_btn_rect): diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 0546348cdd..805fdcc71f 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -225,7 +225,7 @@ class GuiApplication: for layout in KEYBOARD_LAYOUTS.values(): all_chars.update(key for row in layout for key in row) all_chars = "".join(all_chars) - all_chars += "–✓" + all_chars += "–✓°" codepoint_count = rl.ffi.new("int *", 1) codepoints = rl.load_codepoints(all_chars, codepoint_count) diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py index e71c50c288..ad0256564e 100644 --- a/system/ui/lib/list_view.py +++ b/system/ui/lib/list_view.py @@ -1,9 +1,7 @@ import os import pyray as rl -from dataclasses import dataclass from collections.abc import Callable 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.text_measure import measure_text_cached 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.widget import Widget +ITEM_BASE_WIDTH = 600 ITEM_BASE_HEIGHT = 170 -LINE_PADDING = 40 -LINE_COLOR = rl.GRAY ITEM_PADDING = 20 -ITEM_SPACING = 80 ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_COLOR = rl.WHITE 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 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__() - self.width = width + self.set_rect(rl.Rectangle(0, 0, width, 0)) self._enabled_source = enabled @property def enabled(self): return _resolve_value(self._enabled_source, False) - def get_width(self) -> int: - return self.width - class ToggleAction(ItemAction): 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: 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 def set_state(self, state: bool): @@ -160,12 +153,12 @@ class MultipleButtonAction(ItemAction): def _render(self, rect: rl.Rectangle) -> bool: spacing = 20 - button_y = rect.y + (rect.height - 100) / 2 + button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 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) + button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT) # Check button state mouse_pos = rl.get_mouse_position() @@ -187,7 +180,7 @@ class MultipleButtonAction(ItemAction): # 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 + 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)) # Handle click @@ -202,26 +195,92 @@ class MultipleButtonAction(ItemAction): return False -@dataclass -class ListItem: - title: str - icon: str | None = None - description: str | Callable[[], str] | None = None - description_visible: bool = False - rect: "rl.Rectangle" = rl.Rectangle(0, 0, 0, 0) - callback: Callable | None = None - action_item: ItemAction | None = None - visible: bool | Callable[[], bool] = True +class ListItem(Widget): + def __init__(self, title: str = "", icon: str | None = None, description: str | Callable[[], str] | None = None, + description_visible: bool = False, callback: Callable | None = None, + action_item: ItemAction | None = None): + super().__init__() + self.title = title + self.icon = icon + self.description = description + self.description_visible = description_visible + self.callback = callback + self.action_item = action_item - # Cached properties for performance - _prev_max_width: int = 0 - _wrapped_description: str | None = None - _prev_description: str | None = None - _description_height: float = 0 + self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT)) + self._font = gui_app.font(FontWeight.NORMAL) - @property - def is_visible(self) -> bool: - return bool(_resolve_value(self.visible, True)) + # 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 + + 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): return _resolve_value(self.description, None) @@ -247,15 +306,15 @@ class ListItem: return ITEM_BASE_HEIGHT def get_content_width(self, total_width: int) -> int: - if self.action_item and self.action_item.get_width() > 0: - return total_width - self.action_item.get_width() - RIGHT_ITEM_PADDING + if self.action_item and self.action_item.rect.width > 0: + return total_width - int(self.action_item.rect.width) - RIGHT_ITEM_PADDING return total_width def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: if not self.action_item: 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) return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, 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) -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 -def simple_item(title: str, callback: Callable | None = None, visible: bool | Callable[[], bool] = True) -> ListItem: - return ListItem(title=title, callback=callback, visible=visible) +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, - visible: bool | Callable[[], bool] = True) -> ListItem: + 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, 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, - callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, - visible: bool | Callable[[], bool] = True) -> ListItem: + 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, 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, - callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, - visible: bool | Callable[[], bool] = True) -> ListItem: + 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, 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, - description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True, - visible: bool | Callable[[], bool] = True) -> ListItem: + description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: 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, diff --git a/system/ui/lib/scroll_panel.py b/system/ui/lib/scroll_panel.py index df20034a48..32f3b7b575 100644 --- a/system/ui/lib/scroll_panel.py +++ b/system/ui/lib/scroll_panel.py @@ -58,13 +58,15 @@ class GuiScrollPanel: if mouse_pos.x >= scrollbar_x: 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._start_mouse_y = mouse_pos.y self._last_drag_time = current_time self._velocity_history.clear() self._velocity_y = 0.0 self._bounce_offset = 0.0 - self._is_dragging = False # Handle active dragging if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR: @@ -167,13 +169,8 @@ class GuiScrollPanel: return self._offset - def is_click_valid(self) -> bool: - # Check if this is a click rather than a drag - return ( - self._scroll_state == ScrollState.IDLE - and not self._is_dragging - and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) - ) + def is_touch_valid(self): + return not self._is_dragging def get_normalized_scroll_position(self) -> float: """Returns the current scroll position as a value from 0.0 to 1.0""" diff --git a/system/ui/lib/scroller.py b/system/ui/lib/scroller.py new file mode 100644 index 0000000000..8a10221772 --- /dev/null +++ b/system/ui/lib/scroller.py @@ -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() diff --git a/system/ui/lib/widget.py b/system/ui/lib/widget.py index f1161392ce..3539f5594b 100644 --- a/system/ui/lib/widget.py +++ b/system/ui/lib/widget.py @@ -13,21 +13,45 @@ class DialogResult(IntEnum): class Widget(abc.ABC): def __init__(self): 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_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 def is_visible(self) -> bool: 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: self._is_visible = visible 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 - if (rect.x != prev_rect.x or rect.y != prev_rect.y or - rect.width != prev_rect.width or rect.height != prev_rect.height): + if changed: + 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() 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 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): 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): self._handle_mouse_release(mouse_pos) self._is_pressed = False diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 6c5c63be00..0613bb1f12 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -117,7 +117,7 @@ class WifiManagerUI(Widget): def _draw_network_list(self, rect: rl.Rectangle): content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT) 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)) for i, network in enumerate(self._networks): diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py index 59719307f8..a2958ee1f4 100644 --- a/system/ui/widgets/option_dialog.py +++ b/system/ui/widgets/option_dialog.py @@ -42,7 +42,7 @@ class MultiOptionDialog(Widget): # Scroll and render options 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)) for i, option in enumerate(self.options):