diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index de1bcaac4b..b51efd7000 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, toggle_item -from openpilot.system.ui.lib.widget import Widget +from openpilot.system.ui.lib.list_view import ListView, ToggleItem from openpilot.common.params import Params -from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item +from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyItem +from openpilot.system.ui.lib.widget import Widget # Description constants DESCRIPTIONS = { @@ -16,34 +16,33 @@ DESCRIPTIONS = { ), } - class DeveloperLayout(Widget): def __init__(self): super().__init__() self._params = Params() items = [ - toggle_item( + ToggleItem( "Enable ADB", - description=DESCRIPTIONS["enable_adb"], + DESCRIPTIONS["enable_adb"], initial_state=self._params.get_bool("AdbEnabled"), callback=self._on_enable_adb, ), - ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]), - toggle_item( + SshKeyItem("SSH Key", description=DESCRIPTIONS["ssh_key"]), + ToggleItem( "Joystick Debug Mode", - description=DESCRIPTIONS["joystick_debug_mode"], + DESCRIPTIONS["joystick_debug_mode"], initial_state=self._params.get_bool("JoystickDebugMode"), callback=self._on_joystick_debug_mode, ), - toggle_item( + ToggleItem( "Longitudinal Maneuver Mode", - description="", + "", initial_state=self._params.get_bool("LongitudinalManeuverMode"), callback=self._on_long_maneuver_mode, ), - toggle_item( + ToggleItem( "openpilot Longitudinal Control (Alpha)", - description="", + "", initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), callback=self._on_alpha_long_enabled, ), @@ -54,7 +53,7 @@ class DeveloperLayout(Widget): def _render(self, rect): self._list_widget.render(rect) - 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 + 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 diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 79c90dc009..7f8c39fb9a 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, text_item, button_item, dual_button_item +from openpilot.system.ui.lib.list_view import ListView, TextItem, ButtonItem, DualButtonItem 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 = [ - 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), + 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), ] return items diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index af45fa8ee6..a844c46a7e 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -115,13 +115,12 @@ 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, button_rect.y + (button_rect.height - text_size.y) / 2 + button_rect.x + button_rect.width - text_size.x, 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 39b883984e..dabac0adcd 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, button_item, text_item +from openpilot.system.ui.lib.list_view import ListView, ButtonItem, TextItem 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 = [ - 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), + 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), ] return items diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 2c18e44eff..9ea4df6208 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, multiple_button_item, toggle_item +from openpilot.system.ui.lib.list_view import ListView, MultipleButtonItem, ToggleItem 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 = [ - toggle_item( + ToggleItem( "Enable openpilot", DESCRIPTIONS["OpenpilotEnabledToggle"], self._params.get_bool("OpenpilotEnabledToggle"), icon="chffr_wheel.png", ), - toggle_item( + ToggleItem( "Experimental Mode", initial_state=self._params.get_bool("ExperimentalMode"), icon="experimental_white.png", ), - toggle_item( + ToggleItem( "Disengage on Accelerator Pedal", DESCRIPTIONS["DisengageOnAccelerator"], self._params.get_bool("DisengageOnAccelerator"), icon="disengage_on_accelerator.png", ), - multiple_button_item( + MultipleButtonItem( "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" ), - toggle_item( + ToggleItem( "Enable Lane Departure Warnings", DESCRIPTIONS["IsLdwEnabled"], self._params.get_bool("IsLdwEnabled"), icon="warning.png", ), - toggle_item( + ToggleItem( "Always-On Driver Monitoring", DESCRIPTIONS["AlwaysOnDM"], self._params.get_bool("AlwaysOnDM"), icon="monitoring.png", ), - toggle_item( + ToggleItem( "Record and Upload Driver Camera", DESCRIPTIONS["RecordFront"], self._params.get_bool("RecordFront"), icon="monitoring.png", ), - toggle_item( + ToggleItem( "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 418867c86f..b0b1c59ea9 100644 --- a/selfdrive/ui/widgets/ssh_key.py +++ b/selfdrive/ui/widgets/ssh_key.py @@ -8,10 +8,8 @@ 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, ) @@ -21,18 +19,18 @@ from openpilot.system.ui.widgets.confirm_dialog import alert_dialog from openpilot.system.ui.widgets.keyboard import Keyboard -class SshKeyActionState(Enum): +class SshKeyState(Enum): LOADING = "LOADING" ADD = "ADD" REMOVE = "REMOVE" -class SshKeyAction(ItemAction): +class SshKeyItem(ListItem): HTTP_TIMEOUT = 15 # seconds MAX_WIDTH = 500 - def __init__(self): - super().__init__(self.MAX_WIDTH, True) + def __init__(self, title: str, description: str): + super().__init__(title, description=description) self._keyboard = Keyboard() self._params = Params() @@ -41,11 +39,14 @@ class SshKeyAction(ItemAction): 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 = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD + self._state = SshKeyState.REMOVE if self._params.get("GithubSshKeys") else SshKeyState.ADD - def _render(self, rect: rl.Rectangle) -> bool: + def render_action(self, rect: rl.Rectangle) -> bool: # Show error dialog if there's an error if self._error_message: message = copy.copy(self._error_message) @@ -71,8 +72,8 @@ class SshKeyAction(ItemAction): rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT ), self._state.value, - is_enabled=self._state != SshKeyActionState.LOADING, - border_radius=BUTTON_BORDER_RADIUS, + is_enabled=self._state != SshKeyState.LOADING, + border_radius=BUTTON_HEIGHT // 2, font_size=BUTTON_FONT_SIZE, button_style=ButtonStyle.LIST_ACTION, ): @@ -81,11 +82,11 @@ class SshKeyAction(ItemAction): return False def _handle_button_click(self): - if self._state == SshKeyActionState.ADD: + if self._state == SshKeyState.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 == SshKeyActionState.REMOVE: + elif self._state == SshKeyState.REMOVE: self._params.remove("GithubUsername") self._params.remove("GithubSshKeys") self._refresh_state() @@ -98,7 +99,7 @@ class SshKeyAction(ItemAction): if not username: return - self._state = SshKeyActionState.LOADING + self._state = SshKeyState.LOADING threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start() def _fetch_ssh_key(self, username: str): @@ -113,16 +114,12 @@ class SshKeyAction(ItemAction): # Success - save keys self._params.put("GithubUsername", username) self._params.put("GithubSshKeys", keys) - self._state = SshKeyActionState.REMOVE + self._state = SshKeyState.REMOVE self._username = username except requests.exceptions.Timeout: self._error_message = "Request timed out" - self._state = SshKeyActionState.ADD + self._state = SshKeyState.ADD except Exception: self._error_message = f"No SSH keys found for user '{username}'" - self._state = SshKeyActionState.ADD - - -def ssh_key_item(title: str, description: str): - return ListItem(title=title, description=description, action_item=SshKeyAction()) + self._state = SshKeyState.ADD diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py index 99690e6008..283e82e773 100644 --- a/system/ui/lib/list_view.py +++ b/system/ui/lib/list_view.py @@ -1,8 +1,6 @@ -import os import pyray as rl -from dataclasses import dataclass from collections.abc import Callable -from abc import ABC +from abc import ABC, abstractmethod 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 @@ -11,154 +9,269 @@ 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_HEIGHT = 170 LINE_PADDING = 40 -LINE_COLOR = rl.GRAY +ITEM_BASE_HEIGHT = 170 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 _resolve_value(value, default=""): + +def _get_value(value, default=""): if callable(value): return value() return value if value is not None else default -# Abstract base class for right-side items -class ItemAction(Widget, ABC): - def __init__(self, width: int = 100, enabled: bool | Callable[[], bool] = True): +class ListItem(Widget, ABC): + def __init__(self, title, description: StrSrc=None, enabled: BoolSrc=True, visible: BoolSrc=True, icon=None): super().__init__() - self.width = width + self.title = title + self._icon = icon + self.description = description + self.show_desc = False + 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 _resolve_value(self._enabled_source, False) + 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 - def get_width(self) -> int: - return self.width + # 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) + # 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) -class ToggleAction(ItemAction): - def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): - super().__init__(width, enabled) + @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) self.toggle = Toggle(initial_state=initial_state) - self.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 - def _render(self, rect: rl.Rectangle) -> bool: + def render_action(self, rect: rl.Rectangle): self.toggle.set_enabled(self.enabled) - self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)) - return False + 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) def set_state(self, state: bool): - self.state = state self.toggle.set_state(state) - def get_state(self) -> bool: - return self.state + def get_state(self): + return self.toggle.get_state() -class ButtonAction(ItemAction): - def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): - super().__init__(width, enabled) - self._text_source = text +class 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 - @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 + def get_button_text(self): + return _get_value(self._button_text_src, "Error") - 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) + def get_action_width(self) -> int: + return BUTTON_WIDTH - @property - def text(self): - return _resolve_value(self._text_source, "Error") + 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() - def _render(self, rect: rl.Rectangle) -> bool: - current_text = self.text - text_size = measure_text_cached(self._font, current_text, ITEM_TEXT_FONT_SIZE) - 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 +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) + + def get_value(self): + return _get_value(self._value_src, "") + + def get_action_width(self) -> int: + return int(measure_text_cached(self._font, self.get_value(), ITEM_TEXT_FONT_SIZE).x + ITEM_PADDING) - 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) + 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) -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 +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 - 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 + def _render(self, rect: rl.Rectangle): + button_width = (rect.width - self._button_spacing) / 2 + button_y = rect.y + (rect.height - self._button_height) / 2 - 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_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_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 + 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) - if left_clicked and self.left_callback: + if left_clicked and self.left_callback is not None: self.left_callback() - return True - if right_clicked and self.right_callback: + if right_clicked and self.right_callback is not None: self.right_callback() - return True - return False -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) +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) self.buttons = buttons self.button_width = button_width - self.selected_button = selected_index + self.selected_index = selected_index self.callback = callback self._font = gui_app.font(FontWeight.MEDIUM) - - def _render(self, rect: rl.Rectangle) -> bool: + 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: spacing = 20 button_y = rect.y + (rect.height - 100) / 2 clicked = -1 @@ -171,15 +284,13 @@ class MultipleButtonAction(ItemAction): 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_button + is_selected = i == self.selected_index - # 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 + 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'] # Draw button rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) @@ -188,265 +299,56 @@ class MultipleButtonAction(ItemAction): 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, rl.Color(228, 228, 228, 255)) + rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) - # Handle click - if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): + # Handle click only if enabled + if self.enabled and is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): clicked = i if clicked >= 0: - self.selected_button = clicked + self.selected_index = 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 = self._update_item_rects(rect) + total_height = sum(item._get_height(int(rect.width)) for item in self.items if item.is_visible) - # Update layout and handle scrolling + # 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)) - for i, item in enumerate(self._items): + y = rect.y + scroll_offset.y + 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, - ) + item_height = item._get_height(int(rect.width)) - 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: + # Skip if outside viewport + if y + item_height < rect.y or y > rect.y + rect.height: + y += item_height 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 + # Render item + item.render(rl.Rectangle(rect.x, y, rect.width, item_height)) - # 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) + # 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 -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) + rl.end_scissor_mode() diff --git a/system/ui/lib/widget.py b/system/ui/lib/widget.py index 437f7069e9..1335eae41a 100644 --- a/system/ui/lib/widget.py +++ b/system/ui/lib/widget.py @@ -36,3 +36,9 @@ 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")