From 3a10bdb1e777a4d90bc25ac0dbf0ce1eac66b357 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 12 Jun 2025 14:17:04 -0700 Subject: [PATCH] Revert "ui: refactor ListView for generic widget support and simplified item architecture" (#35542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "ui: refactor ListView for generic widget support and simplified item …" This reverts commit 32ae9efb3d34958125ca5d16487cd4341e41b97e. --- selfdrive/ui/layouts/settings/developer.py | 33 +- selfdrive/ui/layouts/settings/device.py | 20 +- selfdrive/ui/layouts/settings/settings.py | 3 +- selfdrive/ui/layouts/settings/software.py | 12 +- selfdrive/ui/layouts/settings/toggles.py | 18 +- selfdrive/ui/widgets/ssh_key.py | 37 +- system/ui/lib/list_view.py | 568 ++++++++++++--------- system/ui/lib/widget.py | 6 - 8 files changed, 397 insertions(+), 300 deletions(-) diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index b51efd7000..de1bcaac4b 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.py @@ -1,7 +1,7 @@ -from openpilot.system.ui.lib.list_view import ListView, ToggleItem -from openpilot.common.params import Params -from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyItem +from openpilot.system.ui.lib.list_view import ListView, toggle_item 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 # Description constants DESCRIPTIONS = { @@ -16,33 +16,34 @@ DESCRIPTIONS = { ), } + class DeveloperLayout(Widget): def __init__(self): super().__init__() self._params = Params() items = [ - ToggleItem( + toggle_item( "Enable ADB", - DESCRIPTIONS["enable_adb"], + description=DESCRIPTIONS["enable_adb"], initial_state=self._params.get_bool("AdbEnabled"), callback=self._on_enable_adb, ), - SshKeyItem("SSH Key", description=DESCRIPTIONS["ssh_key"]), - ToggleItem( + ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]), + toggle_item( "Joystick Debug Mode", - DESCRIPTIONS["joystick_debug_mode"], + description=DESCRIPTIONS["joystick_debug_mode"], initial_state=self._params.get_bool("JoystickDebugMode"), callback=self._on_joystick_debug_mode, ), - ToggleItem( + toggle_item( "Longitudinal Maneuver Mode", - "", + description="", initial_state=self._params.get_bool("LongitudinalManeuverMode"), callback=self._on_long_maneuver_mode, ), - ToggleItem( + toggle_item( "openpilot Longitudinal Control (Alpha)", - "", + description="", initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), callback=self._on_alpha_long_enabled, ), @@ -53,7 +54,7 @@ class DeveloperLayout(Widget): def _render(self, rect): self._list_widget.render(rect) - def _on_enable_adb(self, state): pass - def _on_joystick_debug_mode(self, state): pass - def _on_long_maneuver_mode(self, state): pass - def _on_alpha_long_enabled(self, state): pass + def _on_enable_adb(self): pass + def _on_joystick_debug_mode(self): pass + def _on_long_maneuver_mode(self): pass + def _on_alpha_long_enabled(self): pass diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 7f8c39fb9a..79c90dc009 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -7,7 +7,7 @@ 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, TextItem, ButtonItem, DualButtonItem +from openpilot.system.ui.lib.list_view import ListView, text_item, button_item, dual_button_item 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 @@ -44,15 +44,15 @@ class DeviceLayout(Widget): serial = self._params.get("HardwareSerial") or "N/A" items = [ - TextItem("Dongle ID", dongle_id), - TextItem("Serial", serial), - ButtonItem("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device), - ButtonItem("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad), - ButtonItem("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt), - ButtonItem("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI), - ButtonItem("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), - ButtonItem("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad), - DualButtonItem("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt), + text_item("Dongle ID", dongle_id), + text_item("Serial", serial), + 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), + 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), ] return items diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index a844c46a7e..af45fa8ee6 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -115,12 +115,13 @@ class SettingsLayout(Widget): # Draw button text (right-aligned) text_size = measure_text_cached(self._font_medium, panel_info.name, 65) text_pos = rl.Vector2( - button_rect.x + button_rect.width - text_size.x, y + (button_rect.height - text_size.y) / 2 + button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2 ) rl.draw_text_ex(self._font_medium, panel_info.name, text_pos, 65, 0, text_color) # Store button rect for click detection panel_info.button_rect = button_rect + y += NAV_BTN_HEIGHT + button_spacing def _draw_current_panel(self, rect: rl.Rectangle): diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index dabac0adcd..39b883984e 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -1,6 +1,6 @@ from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.list_view import ListView, ButtonItem, TextItem +from openpilot.system.ui.lib.list_view import ListView, button_item, text_item from openpilot.system.ui.lib.widget import Widget, DialogResult from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog @@ -15,11 +15,11 @@ class SoftwareLayout(Widget): def _init_items(self): items = [ - TextItem("Current Version", ""), - ButtonItem("Download", "CHECK", callback=self._on_download_update), - ButtonItem("Install Update", "INSTALL", callback=self._on_install_update), - ButtonItem("Target Branch", "SELECT", callback=self._on_select_branch), - ButtonItem("Uninstall", "UNINSTALL", callback=self._on_uninstall), + text_item("Current Version", ""), + button_item("Download", "CHECK", callback=self._on_download_update), + button_item("Install Update", "INSTALL", callback=self._on_install_update), + button_item("Target Branch", "SELECT", callback=self._on_select_branch), + button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall), ] return items diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 9ea4df6208..2c18e44eff 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -1,4 +1,4 @@ -from openpilot.system.ui.lib.list_view import ListView, MultipleButtonItem, ToggleItem +from openpilot.system.ui.lib.list_view import ListView, multiple_button_item, toggle_item from openpilot.system.ui.lib.widget import Widget from openpilot.common.params import Params @@ -29,24 +29,24 @@ class TogglesLayout(Widget): super().__init__() self._params = Params() items = [ - ToggleItem( + toggle_item( "Enable openpilot", DESCRIPTIONS["OpenpilotEnabledToggle"], self._params.get_bool("OpenpilotEnabledToggle"), icon="chffr_wheel.png", ), - ToggleItem( + toggle_item( "Experimental Mode", initial_state=self._params.get_bool("ExperimentalMode"), icon="experimental_white.png", ), - ToggleItem( + toggle_item( "Disengage on Accelerator Pedal", DESCRIPTIONS["DisengageOnAccelerator"], self._params.get_bool("DisengageOnAccelerator"), icon="disengage_on_accelerator.png", ), - MultipleButtonItem( + multiple_button_item( "Driving Personality", DESCRIPTIONS["LongitudinalPersonality"], buttons=["Aggressive", "Standard", "Relaxed"], @@ -55,25 +55,25 @@ class TogglesLayout(Widget): selected_index=int(self._params.get("LongitudinalPersonality") or 0), icon="speed_limit.png" ), - ToggleItem( + toggle_item( "Enable Lane Departure Warnings", DESCRIPTIONS["IsLdwEnabled"], self._params.get_bool("IsLdwEnabled"), icon="warning.png", ), - ToggleItem( + toggle_item( "Always-On Driver Monitoring", DESCRIPTIONS["AlwaysOnDM"], self._params.get_bool("AlwaysOnDM"), icon="monitoring.png", ), - ToggleItem( + toggle_item( "Record and Upload Driver Camera", DESCRIPTIONS["RecordFront"], self._params.get_bool("RecordFront"), icon="monitoring.png", ), - ToggleItem( + toggle_item( "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png" ), ] diff --git a/selfdrive/ui/widgets/ssh_key.py b/selfdrive/ui/widgets/ssh_key.py index b0b1c59ea9..418867c86f 100644 --- a/selfdrive/ui/widgets/ssh_key.py +++ b/selfdrive/ui/widgets/ssh_key.py @@ -8,8 +8,10 @@ from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.button import gui_button, ButtonStyle from openpilot.system.ui.lib.list_view import ( + ItemAction, ListItem, BUTTON_HEIGHT, + BUTTON_BORDER_RADIUS, BUTTON_FONT_SIZE, BUTTON_WIDTH, ) @@ -19,18 +21,18 @@ from openpilot.system.ui.widgets.confirm_dialog import alert_dialog from openpilot.system.ui.widgets.keyboard import Keyboard -class SshKeyState(Enum): +class SshKeyActionState(Enum): LOADING = "LOADING" ADD = "ADD" REMOVE = "REMOVE" -class SshKeyItem(ListItem): +class SshKeyAction(ItemAction): HTTP_TIMEOUT = 15 # seconds MAX_WIDTH = 500 - def __init__(self, title: str, description: str): - super().__init__(title, description=description) + def __init__(self): + super().__init__(self.MAX_WIDTH, True) self._keyboard = Keyboard() self._params = Params() @@ -39,14 +41,11 @@ class SshKeyItem(ListItem): self._refresh_state() - def get_action_width(self) -> int: - return self.MAX_WIDTH - def _refresh_state(self): self._username = self._params.get("GithubUsername", "") - self._state = SshKeyState.REMOVE if self._params.get("GithubSshKeys") else SshKeyState.ADD + self._state = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD - def render_action(self, rect: rl.Rectangle) -> bool: + def _render(self, rect: rl.Rectangle) -> bool: # Show error dialog if there's an error if self._error_message: message = copy.copy(self._error_message) @@ -72,8 +71,8 @@ class SshKeyItem(ListItem): rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT ), self._state.value, - is_enabled=self._state != SshKeyState.LOADING, - border_radius=BUTTON_HEIGHT // 2, + is_enabled=self._state != SshKeyActionState.LOADING, + border_radius=BUTTON_BORDER_RADIUS, font_size=BUTTON_FONT_SIZE, button_style=ButtonStyle.LIST_ACTION, ): @@ -82,11 +81,11 @@ class SshKeyItem(ListItem): return False def _handle_button_click(self): - if self._state == SshKeyState.ADD: + if self._state == SshKeyActionState.ADD: self._keyboard.clear() self._keyboard.set_title("Enter your GitHub username") gui_app.set_modal_overlay(self._keyboard, callback=self._on_username_submit) - elif self._state == SshKeyState.REMOVE: + elif self._state == SshKeyActionState.REMOVE: self._params.remove("GithubUsername") self._params.remove("GithubSshKeys") self._refresh_state() @@ -99,7 +98,7 @@ class SshKeyItem(ListItem): if not username: return - self._state = SshKeyState.LOADING + self._state = SshKeyActionState.LOADING threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start() def _fetch_ssh_key(self, username: str): @@ -114,12 +113,16 @@ class SshKeyItem(ListItem): # Success - save keys self._params.put("GithubUsername", username) self._params.put("GithubSshKeys", keys) - self._state = SshKeyState.REMOVE + self._state = SshKeyActionState.REMOVE self._username = username except requests.exceptions.Timeout: self._error_message = "Request timed out" - self._state = SshKeyState.ADD + self._state = SshKeyActionState.ADD except Exception: self._error_message = f"No SSH keys found for user '{username}'" - self._state = SshKeyState.ADD + self._state = SshKeyActionState.ADD + + +def ssh_key_item(title: str, description: str): + return ListItem(title=title, description=description, action_item=SshKeyAction()) diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py index 283e82e773..99690e6008 100644 --- a/system/ui/lib/list_view.py +++ b/system/ui/lib/list_view.py @@ -1,6 +1,8 @@ +import os import pyray as rl +from dataclasses import dataclass from collections.abc import Callable -from abc import ABC, abstractmethod +from abc import ABC from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -9,269 +11,154 @@ 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 -LINE_PADDING = 40 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) ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_V_OFFSET = 140 +RIGHT_ITEM_PADDING = 20 ICON_SIZE = 80 BUTTON_WIDTH = 250 BUTTON_HEIGHT = 100 +BUTTON_BORDER_RADIUS = 50 BUTTON_FONT_SIZE = 35 +BUTTON_FONT_WEIGHT = FontWeight.MEDIUM +TEXT_PADDING = 20 -# Type Aliases for Clarity -StrSrc = str | Callable[[], str] | None -BoolSrc = bool | Callable[[], bool] - -def _get_value(value, default=""): +def _resolve_value(value, default=""): if callable(value): return value() return value if value is not None else default -class ListItem(Widget, ABC): - def __init__(self, title, description: StrSrc=None, enabled: BoolSrc=True, visible: BoolSrc=True, icon=None): +# Abstract base class for right-side items +class ItemAction(Widget, ABC): + def __init__(self, width: int = 100, enabled: bool | Callable[[], bool] = True): super().__init__() - self.title = title - self._icon = icon - self.description = description - self.show_desc = False - + self.width = width self._enabled_source = enabled - self._visible_source = visible - self._font = gui_app.font(FontWeight.NORMAL) - - # 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 @property def enabled(self): - return _get_value(self._enabled_source, True) - - @property - def is_visible(self): - return _get_value(self._visible_source, True) - - def set_visible(self, visible: bool): - self._visible_source = visible - - def set_enabled(self, enabled: bool): - self._enabled_source = enabled - - def get_desc(self): - return _get_value(self.description, "") - - def set_icon(self, icon: str): - self._icon = icon - - def set_desc(self, description: StrSrc): - self.description = description - current_description = self.get_desc() - if current_description != self._prev_description: - self._update_description_cache(self._prev_max_width, current_description) - - def _update_description_cache(self, max_width: int, current_description: str): - """Update the cached description wrapping""" - self._prev_max_width = max_width - self._prev_description = current_description - content_width = max_width - ITEM_PADDING * 2 - - # Account for icon width - if self._icon: - content_width -= ICON_SIZE + ITEM_PADDING - - wrapped_lines = wrap_text(self._font, current_description, ITEM_DESC_FONT_SIZE, content_width) - self._wrapped_description = "\n".join(wrapped_lines) - self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 - - def _get_height(self, max_width: int) -> float: - if not self.is_visible: - return 0 - - if not self.show_desc: - return ITEM_BASE_HEIGHT - - current_description = self.get_desc() - if not current_description: - return ITEM_BASE_HEIGHT - - if current_description != self._prev_description or max_width != self._prev_max_width: - self._update_description_cache(max_width, current_description) - - return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING - - def _render(self, rect: rl.Rectangle): - # Handle click on title/description area for toggling description - if self.description and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): - mouse_pos = rl.get_mouse_position() - - text_area_width = rect.width - self.get_action_width() - ITEM_PADDING - text_area = rl.Rectangle(rect.x, rect.y, text_area_width, rect.height) - if rl.check_collision_point_rec(mouse_pos, text_area): - self.show_desc = not self.show_desc - - # Render title and description - x = rect.x + ITEM_PADDING + return _resolve_value(self._enabled_source, False) - # Draw icon if present - if self._icon: - icon_texture = gui_app.texture(f"icons/{self._icon}", ICON_SIZE, ICON_SIZE) - rl.draw_texture(icon_texture, int(x), int(rect.y + (ITEM_BASE_HEIGHT - ICON_SIZE) // 2), rl.WHITE) - x += ICON_SIZE + ITEM_PADDING - - text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE) - title_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 - rl.draw_text_ex(self._font, self.title, (x, title_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) - - # Draw description if visible - if self.show_desc and self._wrapped_description: - rl.draw_text_ex(self._font, self._wrapped_description, (x, rect.y + ITEM_DESC_V_OFFSET), - ITEM_DESC_FONT_SIZE, 0, ITEM_DESC_TEXT_COLOR) + def get_width(self) -> int: + return self.width - # Render action if needed - action_width = self.get_action_width() - action_rect = rl.Rectangle(rect.x + rect.width - action_width, rect.y, action_width, ITEM_BASE_HEIGHT) - self.render_action(action_rect) - @abstractmethod - def get_action_width(self) -> int: - """Return the width needed for the action part (right side)""" - - @abstractmethod - def render_action(self, rect: rl.Rectangle): - """Render the action part""" - - -class ToggleItem(ListItem): - def __init__(self, title: str, description: StrSrc = None, initial_state: bool=False, callback=None, active_icon=None, **kwargs): - super().__init__(title, description, **kwargs) +class ToggleAction(ItemAction): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): + super().__init__(width, enabled) self.toggle = Toggle(initial_state=initial_state) - self.callback = callback - self._inactive_icon = kwargs.get('icon', None) - self._active_icon = active_icon - if self._active_icon and initial_state: - self.set_icon(self._active_icon) - - def get_action_width(self) -> int: - return TOGGLE_WIDTH + self.state = initial_state - def render_action(self, rect: rl.Rectangle): + def _render(self, rect: rl.Rectangle) -> bool: self.toggle.set_enabled(self.enabled) - toggle_rect = rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) // 2, - TOGGLE_WIDTH, TOGGLE_HEIGHT) - - if self.toggle.render(toggle_rect): - if self._active_icon and self._inactive_icon: - self.set_icon(self._active_icon if self.toggle.get_state() else self._inactive_icon) - - if self.callback: - self.callback(self) + self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)) + return False def set_state(self, state: bool): + self.state = state self.toggle.set_state(state) - def get_state(self): - return self.toggle.get_state() + def get_state(self) -> bool: + return self.state -class ButtonItem(ListItem): - def __init__(self, title: str, button_text, description=None, callback=None, **kwargs): - super().__init__(title, description, **kwargs) - self._button_text_src = button_text - self._callback = callback +class ButtonAction(ItemAction): + def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): + super().__init__(width, enabled) + self._text_source = text - def get_button_text(self): - return _get_value(self._button_text_src, "Error") - - def get_action_width(self) -> int: - return BUTTON_WIDTH - - def render_action(self, rect: rl.Rectangle): - button_rect = rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) // 2, BUTTON_WIDTH, BUTTON_HEIGHT) - if gui_button(button_rect, self.get_button_text(), border_radius=BUTTON_HEIGHT // 2, - font_size=BUTTON_FONT_SIZE, button_style=ButtonStyle.LIST_ACTION, is_enabled=self.enabled): - if self._callback: - self._callback() + @property + def text(self): + return _resolve_value(self._text_source, "Error") + + def _render(self, rect: rl.Rectangle) -> bool: + return gui_button( + rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT), + self.text, + border_radius=BUTTON_BORDER_RADIUS, + font_weight=BUTTON_FONT_WEIGHT, + font_size=BUTTON_FONT_SIZE, + button_style=ButtonStyle.LIST_ACTION, + is_enabled=self.enabled, + ) == 1 + + +class TextAction(ItemAction): + def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True): + self._text_source = text + self.color = color + self._font = gui_app.font(FontWeight.NORMAL) + initial_text = _resolve_value(text, "") + text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x + super().__init__(int(text_width + TEXT_PADDING), enabled) -class TextItem(ListItem): - def __init__(self, title: str, value: str | Callable[[], str], **kwargs): - super().__init__(title, **kwargs) - self._value_src = value - self.color = rl.Color(170, 170, 170, 255) + @property + def text(self): + return _resolve_value(self._text_source, "Error") - def get_value(self): - return _get_value(self._value_src, "") + def _render(self, rect: rl.Rectangle) -> bool: + current_text = self.text + text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE) - def get_action_width(self) -> int: - return int(measure_text_cached(self._font, self.get_value(), ITEM_TEXT_FONT_SIZE).x + ITEM_PADDING) + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color) + return False - def render_action(self, rect: rl.Rectangle): - value = self.get_value() - text_size = measure_text_cached(self._font, value, ITEM_TEXT_FONT_SIZE) - x = rect.x + (rect.width - text_size.x) // 2 - y = rect.y + (rect.height - text_size.y) // 2 - rl.draw_text_ex(self._font, value, rl.Vector2(x, y), ITEM_TEXT_FONT_SIZE, 0, self.color) + def get_width(self) -> int: + text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x + return int(text_width + TEXT_PADDING) -class DualButtonItem(Widget): - def __init__(self, left_text: str, right_text: str, left_callback: Callable, right_callback: Callable): - super().__init__() - self.left_text = left_text - self.right_text = right_text - self.left_callback = left_callback - self.right_callback = right_callback - self._button_spacing = 30 - self._button_height = 120 - - def _get_height(self, max_width: int) -> float: - return ITEM_BASE_HEIGHT +class DualButtonAction(ItemAction): + def __init__(self, left_text: str, right_text: str, left_callback: Callable = None, + right_callback: Callable = None, enabled: bool | Callable[[], bool] = True): + super().__init__(width=0, enabled=enabled) # Width 0 means use full width + self.left_text, self.right_text = left_text, right_text + self.left_callback, self.right_callback = left_callback, right_callback - def _render(self, rect: rl.Rectangle): - button_width = (rect.width - self._button_spacing) / 2 - button_y = rect.y + (rect.height - self._button_height) / 2 + def _render(self, rect: rl.Rectangle) -> bool: + button_spacing = 30 + button_height = 120 + button_width = (rect.width - button_spacing) / 2 + button_y = rect.y + (rect.height - button_height) / 2 - left_rect = rl.Rectangle(rect.x, button_y, button_width, self._button_height) - right_rect = rl.Rectangle(rect.x + button_width + self._button_spacing, button_y, button_width, self._button_height) + left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height) + right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height) - left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) - right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) + left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) == 1 + right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) == 1 - if left_clicked and self.left_callback is not None: + if left_clicked and self.left_callback: self.left_callback() - if right_clicked and self.right_callback is not None: + return True + if right_clicked and self.right_callback: self.right_callback() + return True + return False -class MultipleButtonItem(ListItem): - def __init__(self, title: str, description: str, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None, **kwargs): - super().__init__(title, description, **kwargs) +class MultipleButtonAction(ItemAction): + def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None): + super().__init__(width=len(buttons) * (button_width + 20), enabled=True) self.buttons = buttons self.button_width = button_width - self.selected_index = selected_index + self.selected_button = selected_index self.callback = callback self._font = gui_app.font(FontWeight.MEDIUM) - self._colors = { - 'normal': rl.Color(57, 57, 57, 255), # Gray - 'hovered': rl.Color(74, 74, 74, 255), # Dark gray - 'selected': rl.Color(51, 171, 76, 255), # Green - 'disabled': rl.Color(153, 51, 171, 76), # #9933Ab4C - Semi-transparent - 'text': rl.Color(228, 228, 228, 255), # Light gray - 'text_disabled': rl.Color(51, 228, 228, 228), # #33E4E4E4 - Semi-transparent - } - - def get_action_width(self) -> int: - return self.button_width * len(self.buttons) + (len(self.buttons) - 1) * 20 - - def render_action(self, rect: rl.Rectangle) -> bool: + + def _render(self, rect: rl.Rectangle) -> bool: spacing = 20 button_y = rect.y + (rect.height - 100) / 2 clicked = -1 @@ -284,13 +171,15 @@ class MultipleButtonItem(ListItem): mouse_pos = rl.get_mouse_position() is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect) is_pressed = is_hovered and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) - is_selected = i == self.selected_index + is_selected = i == self.selected_button - bg_color = (self._colors['disabled'] if not self.enabled and is_selected else - self._colors['selected'] if is_selected else - self._colors['hovered'] if is_pressed and self.enabled else - self._colors['normal']) - text_color = self._colors['text_disabled'] if not self.enabled else self._colors['text'] + # Button colors + if is_selected: + bg_color = rl.Color(51, 171, 76, 255) # Green + elif is_pressed: + bg_color = rl.Color(74, 74, 74, 255) # Dark gray + else: + bg_color = rl.Color(57, 57, 57, 255) # Gray # Draw button rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) @@ -299,56 +188,265 @@ class MultipleButtonItem(ListItem): 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 - rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, rl.Color(228, 228, 228, 255)) - # Handle click only if enabled - if self.enabled and is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): + # Handle click + if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): clicked = i if clicked >= 0: - self.selected_index = clicked + self.selected_button = clicked if self.callback: self.callback(clicked) return True 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 + + # Cached properties for performance + _prev_max_width: int = 0 + _wrapped_description: str | None = None + _prev_description: str | None = None + _description_height: float = 0 + + @property + def is_visible(self) -> bool: + return bool(_resolve_value(self.visible, True)) + + def get_description(self): + return _resolve_value(self.description, None) + + def get_item_height(self, font: rl.Font, max_width: int) -> float: + if not self.is_visible: + return 0 + + current_description = self.get_description() + if self.description_visible and current_description: + if ( + not self._wrapped_description + or current_description != self._prev_description + or max_width != self._prev_max_width + ): + self._prev_max_width = max_width + self._prev_description = current_description + + wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width) + self._wrapped_description = "\n".join(wrapped_lines) + self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 + return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING + return ITEM_BASE_HEIGHT + + def get_content_width(self, total_width: int) -> int: + if self.action_item and self.action_item.get_width() > 0: + return total_width - self.action_item.get_width() - RIGHT_ITEM_PADDING + return total_width + + def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: + if not self.action_item: + return rl.Rectangle(0, 0, 0, 0) + + right_width = self.action_item.get_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) + + right_x = item_rect.x + item_rect.width - right_width + right_y = item_rect.y + return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) + + class ListView(Widget): def __init__(self, items: list[ListItem]): super().__init__() - self.items = items + self._items = items self.scroll_panel = GuiScrollPanel() + self._font = gui_app.font(FontWeight.NORMAL) + self._hovered_item = -1 def _render(self, rect: rl.Rectangle): - total_height = sum(item._get_height(int(rect.width)) for item in self.items if item.is_visible) + total_height = self._update_item_rects(rect) - # Handle scrolling + # Update layout and handle scrolling content_rect = rl.Rectangle(rect.x, rect.y, rect.width, total_height) scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) + # Handle mouse interaction + 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)) - y = rect.y + scroll_offset.y - for i, item in enumerate(self.items): + for i, item in enumerate(self._items): if not item.is_visible: continue - item_height = item._get_height(int(rect.width)) - - # Skip if outside viewport - if y + item_height < rect.y or y > rect.y + rect.height: - y += item_height + y = int(item.rect.y + scroll_offset.y) + if y + item.rect.height <= rect.y or y >= rect.y + rect.height: continue - # Render item - item.render(rl.Rectangle(rect.x, y, rect.width, item_height)) + self._render_item(item, y) # Draw separator line - if i < len(self.items) - 1: - line_y = int(y + item_height - 1) - rl.draw_line(int(rect.x + ITEM_PADDING), line_y, int(rect.x + rect.width - ITEM_PADDING), line_y, rl.GRAY) - - y += item_height + 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_item_rects(self, container_rect: rl.Rectangle) -> float: + current_y = 0.0 + for item in self._items: + if not item.is_visible: + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, 0) + continue + + content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) + item_height = item.get_item_height(self._font, content_width) + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) + current_y += item_height + return 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 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: + action = ToggleAction(initial_state=initial_state, enabled=enabled) + return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback, visible=visible) + + +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: + action = ButtonAction(text=button_text, enabled=enabled) + return ListItem(title=title, description=description, action_item=action, callback=callback, visible=visible) + + +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: + 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) + + +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: + action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled) + return ListItem(title="", description=description, action_item=action, visible=visible) + + +def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int, + button_width: int = BUTTON_WIDTH, callback: Callable = None, icon: str = ""): + action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback) + return ListItem(title=title, description=description, icon=icon, action_item=action) diff --git a/system/ui/lib/widget.py b/system/ui/lib/widget.py index 6c274124f8..1823241d02 100644 --- a/system/ui/lib/widget.py +++ b/system/ui/lib/widget.py @@ -43,9 +43,3 @@ class Widget(abc.ABC): def _handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: """Handle mouse release events, if applicable.""" return False - - def is_visible(self): - return True - - def _get_height(self, max_width: int) -> float: - raise NotImplementedError("Subclasses must implement the get_height method")