mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-18 14:53:51 +08:00
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 * ??
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+104
-198
@@ -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,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
+32
-5
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user