diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py new file mode 100644 index 0000000000..6ab5be2792 --- /dev/null +++ b/selfdrive/ui/layouts/settings/developer.py @@ -0,0 +1,52 @@ +from openpilot.system.ui.lib.list_view import ListView, toggle_item +from openpilot.common.params import Params + +# Description constants +DESCRIPTIONS = { + 'enable_adb': ( + "ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. " + + "See https://docs.comma.ai/how-to/connect-to-comma for more info." + ), + 'joystick_debug_mode': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", +} + + +class DeveloperLayout: + def __init__(self): + self._params = Params() + items = [ + toggle_item( + "Enable ADB", + description=DESCRIPTIONS["enable_adb"], + initial_state=self._params.get_bool("AdbEnabled"), + callback=self._on_enable_adb, + ), + toggle_item( + "Joystick Debug Mode", + description=DESCRIPTIONS["joystick_debug_mode"], + initial_state=self._params.get_bool("JoystickDebugMode"), + callback=self._on_joystick_debug_mode, + ), + toggle_item( + "Longitudinal Maneuver Mode", + description="", + initial_state=self._params.get_bool("LongitudinalManeuverMode"), + callback=self._on_long_maneuver_mode, + ), + toggle_item( + "openpilot Longitudinal Control (Alpha)", + description="", + initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), + callback=self._on_alpha_long_enabled, + ), + ] + + self._list_widget = ListView(items) + + 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 diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py new file mode 100644 index 0000000000..5051dd6ba6 --- /dev/null +++ b/selfdrive/ui/layouts/settings/device.py @@ -0,0 +1,47 @@ +from openpilot.system.ui.lib.list_view import ListView, text_item, button_item +from openpilot.common.params import Params +from openpilot.system.hardware import TICI + +# Description constants +DESCRIPTIONS = { + 'pair_device': "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer.", + 'driver_camera': "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)", + 'reset_calibration': ( + "openpilot requires the device to be mounted within 4° left or right and within 5° " + + "up or 9° down. openpilot is continuously calibrating, resetting is rarely required." + ), + 'review_guide': "Review the rules, features, and limitations of openpilot", +} + + +class DeviceLayout: + def __init__(self): + params = Params() + dongle_id = params.get("DongleId", encoding="utf-8") or "N/A" + serial = 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'], self._on_pair_device), + button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], self._on_driver_camera), + button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], self._on_reset_calibration), + button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), + ] + + if TICI: + items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory)) + + items.append(button_item("Change Language", "CHANGE", callback=self._on_change_language)) + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_pair_device(self): pass + def _on_driver_camera(self): pass + def _on_reset_calibration(self): pass + def _on_review_training_guide(self): pass + def _on_regulatory(self): pass + def _on_change_language(self): pass diff --git a/selfdrive/ui/layouts/settings/settings.py b/selfdrive/ui/layouts/settings/settings.py index 6a9a50b855..fc03529e56 100644 --- a/selfdrive/ui/layouts/settings/settings.py +++ b/selfdrive/ui/layouts/settings/settings.py @@ -3,6 +3,10 @@ from dataclasses import dataclass from enum import IntEnum from collections.abc import Callable from openpilot.common.params import Params +from openpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout +from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout +from openpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout +from openpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.label import gui_text_box @@ -52,12 +56,12 @@ class SettingsLayout: # Panel configuration self._panels = { - PanelType.DEVICE: PanelInfo("Device", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.TOGGLES: PanelInfo("Toggles", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.SOFTWARE: PanelInfo("Software", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.DEVICE: PanelInfo("Device", DeviceLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayout(), rl.Rectangle(0, 0, 0, 0)), + PanelType.SOFTWARE: PanelInfo("Software", SoftwareLayout(), rl.Rectangle(0, 0, 0, 0)), PanelType.FIREHOSE: PanelInfo("Firehose", None, rl.Rectangle(0, 0, 0, 0)), PanelType.NETWORK: PanelInfo("Network", None, rl.Rectangle(0, 0, 0, 0)), - PanelType.DEVELOPER: PanelInfo("Developer", None, rl.Rectangle(0, 0, 0, 0)), + PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayout(), rl.Rectangle(0, 0, 0, 0)), } self._font_medium = gui_app.font(FontWeight.MEDIUM) @@ -130,16 +134,23 @@ class SettingsLayout: i += 1 def _draw_current_panel(self, rect: rl.Rectangle): - content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50) - rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR) - gui_text_box( - content_rect, - f"Demo {self._panels[self._current_panel].name} Panel", - font_size=170, - color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + rl.draw_rectangle_rounded( + rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR ) + content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50) + # rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR) + panel = self._panels[self._current_panel] + if panel.instance: + panel.instance.render(content_rect) + else: + gui_text_box( + content_rect, + f"Demo {self._panels[self._current_panel].name} Panel", + font_size=170, + color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + ) def handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: # Check close button diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py new file mode 100644 index 0000000000..e57e13c148 --- /dev/null +++ b/selfdrive/ui/layouts/settings/software.py @@ -0,0 +1,21 @@ +from openpilot.system.ui.lib.list_view import ListView, button_item, text_item + +class SoftwareLayout: + def __init__(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), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) + + def _on_download_update(self): pass + def _on_install_update(self): pass + def _on_select_branch(self): pass + def _on_uninstall(self): pass diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py new file mode 100644 index 0000000000..d966baf9cd --- /dev/null +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -0,0 +1,68 @@ +from openpilot.system.ui.lib.list_view import ListView, toggle_item +from openpilot.common.params import Params + +# Description constants +DESCRIPTIONS = { + "OpenpilotEnabledToggle": ( + "Use the openpilot system for adaptive cruise control and lane keep driver assistance. " + + "Your attention is required at all times to use this feature." + ), + "DisengageOnAccelerator": "When enabled, pressing the accelerator pedal will disengage openpilot.", + "IsLdwEnabled": ( + "Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " + + "without a turn signal activated while driving over 31 mph (50 km/h)." + ), + "AlwaysOnDM": "Enable driver monitoring even when openpilot is not engaged.", + 'RecordFront': "Upload data from the driver facing camera and help improve the driver monitoring algorithm.", + "IsMetric": "Display speed in km/h instead of mph.", +} + + +class TogglesLayout: + def __init__(self): + self._params = Params() + items = [ + toggle_item( + "Enable openpilot", + DESCRIPTIONS["OpenpilotEnabledToggle"], + self._params.get_bool("OpenpilotEnabledToggle"), + icon="chffr_wheel.png", + ), + toggle_item( + "Experimental Mode", + initial_state=self._params.get_bool("ExperimentalMode"), + icon="experimental_white.png", + ), + toggle_item( + "Disengage on Accelerator Pedal", + DESCRIPTIONS["DisengageOnAccelerator"], + self._params.get_bool("DisengageOnAccelerator"), + icon="disengage_on_accelerator.png", + ), + toggle_item( + "Enable Lane Departure Warnings", + DESCRIPTIONS["IsLdwEnabled"], + self._params.get_bool("IsLdwEnabled"), + icon="warning.png", + ), + toggle_item( + "Always-On Driver Monitoring", + DESCRIPTIONS["AlwaysOnDM"], + self._params.get_bool("AlwaysOnDM"), + icon="monitoring.png", + ), + toggle_item( + "Record and Upload Driver Camera", + DESCRIPTIONS["RecordFront"], + self._params.get_bool("RecordFront"), + icon="monitoring.png", + ), + toggle_item( + "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png" + ), + ] + + self._list_widget = ListView(items) + + def render(self, rect): + self._list_widget.render(rect) diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py new file mode 100644 index 0000000000..9ca2363bc7 --- /dev/null +++ b/system/ui/lib/list_view.py @@ -0,0 +1,380 @@ +import os +import pyray as rl +from dataclasses import dataclass +from collections.abc import Callable +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 +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.lib.button import gui_button +from openpilot.system.ui.lib.toggle import Toggle +from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT + + +LINE_PADDING = 40 +LINE_COLOR = rl.GRAY +ITEM_PADDING = 20 +ITEM_SPACING = 80 +ITEM_BASE_HEIGHT = 170 +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 = 130 +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 + + +# Abstract base class for right-side items +class RightItem(ABC): + def __init__(self, width: int = 100): + self.width = width + self.enabled = True + + @abstractmethod + def draw(self, rect: rl.Rectangle) -> bool: + pass + + @abstractmethod + def get_width(self) -> int: + pass + + +class ToggleRightItem(RightItem): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH): + super().__init__(width) + self.toggle = Toggle(initial_state=initial_state) + self.state = initial_state + self.enabled = True + + def draw(self, rect: rl.Rectangle) -> bool: + if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)): + self.state = not self.state + return True + return False + + def get_width(self) -> int: + return self.width + + def set_state(self, state: bool): + self.state = state + self.toggle.set_state(state) + + def get_state(self) -> bool: + return self.state + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + +class ButtonRightItem(RightItem): + def __init__(self, text: str, width: int = BUTTON_WIDTH): + super().__init__(width) + self.text = text + self.enabled = True + + def draw(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, + is_enabled=self.enabled, + ) + == 1 + ) + + def get_width(self) -> int: + return self.width + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + +class TextRightItem(RightItem): + def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE): + self.text = text + self.color = color + self.font_size = font_size + + font = gui_app.font(FontWeight.NORMAL) + text_width = measure_text_cached(font, text, font_size).x + super().__init__(int(text_width + 20)) + + def draw(self, rect: rl.Rectangle) -> bool: + font = gui_app.font(FontWeight.NORMAL) + text_size = measure_text_cached(font, self.text, self.font_size) + + # Center the text in the allocated rectangle + text_x = rect.x + (rect.width - text_size.x) / 2 + text_y = rect.y + (rect.height - text_size.y) / 2 + + rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color) + return False + + def get_width(self) -> int: + return self.width + + def set_text(self, text: str): + self.text = text + font = gui_app.font(FontWeight.NORMAL) + text_width = measure_text_cached(font, text, self.font_size).x + self.width = int(text_width + 20) + + +@dataclass +class ListItem: + title: str + icon: str | None = None + description: str | None = None + description_visible: bool = False + rect: "rl.Rectangle | None" = None + callback: Callable | None = None + right_item: RightItem | None = None + + # Cached properties for performance + _wrapped_description: str | None = None + _description_height: float = 0 + + def get_right_item(self) -> RightItem | None: + return self.right_item + + def get_item_height(self, font: rl.Font, max_width: int) -> float: + if self.description_visible and self.description: + if not self._wrapped_description: + wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width) + self._wrapped_description = "\n".join(wrapped_lines) + self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding + return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING + return ITEM_BASE_HEIGHT + + def get_content_width(self, total_width: int) -> int: + if self.right_item: + return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING + return total_width + + def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: + if not self.right_item: + return rl.Rectangle(0, 0, 0, 0) + + right_width = self.right_item.get_width() + 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: + def __init__(self, items: list[ListItem]): + self._items: list[ListItem] = items + self._last_dim: tuple[float, float] = (0, 0) + self.scroll_panel = GuiScrollPanel() + + self._font_normal = gui_app.font(FontWeight.NORMAL) + + # Interaction state + self._hovered_item: int = -1 + self._last_mouse_pos = rl.Vector2(0, 0) + + self._total_height: float = 0 + self._visible_range = (0, 0) + + def invalid_height_cache(self): + self._last_dim = (0, 0) + + def render(self, rect: rl.Rectangle): + if self._last_dim != (rect.width, rect.height): + self._update_item_rects(rect) + self._last_dim = (rect.width, rect.height) + + # 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)) + + # Calculate visible range for performance + self._calculate_visible_range(rect, -scroll_offset.y) + + # Render only visible items + for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))): + item = self._items[i] + if item.rect: + adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height) + self._render_item(item, adjusted_rect, i) + + if i != len(self._items) - 1: + rl.draw_line_ex( + rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1), + rl.Vector2( + adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1 + ), + 1.0, + LINE_COLOR, + ) + rl.end_scissor_mode() + + def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int): + content_x = rect.x + ITEM_PADDING + text_x = content_x + + # Calculate available width for main content + content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2)) + + # 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(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_normal, item.title, ITEM_TEXT_FONT_SIZE) + item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 + rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) + + # Draw description if visible (adjust width for right item) + if item.description_visible and item._wrapped_description: + desc_y = rect.y + ITEM_DESC_V_OFFSET + desc_max_width = int(content_width - (text_x - content_x)) + + # Re-wrap description if needed due to right item + if (item.right_item and item.description) and not item._wrapped_description: + wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width) + item._wrapped_description = "\n".join(wrapped_lines) + + rl.draw_text_ex( + self._font_normal, + item._wrapped_description, + rl.Vector2(text_x, desc_y), + ITEM_DESC_FONT_SIZE, + 0, + ITEM_DESC_TEXT_COLOR, + ) + + # Draw right item if present + if item.right_item: + right_rect = item.get_right_item_rect(rect) + # Adjust for scroll offset + right_rect.y = right_rect.y + if item.right_item.draw(right_rect): + # Right item was clicked/activated + if item.callback: + item.callback() + + def _update_item_rects(self, container_rect: rl.Rectangle) -> None: + current_y: float = 0.0 + self._total_height = 0 + + for item in self._items: + content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) + item_height = item.get_item_height(self._font_normal, content_width) + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) + current_y += item_height + self._total_height += item_height + + def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float): + if not self._items: + self._visible_range = (0, 0) + return + + visible_top = scroll_offset + visible_bottom = scroll_offset + rect.height + + start_idx = 0 + end_idx = len(self._items) + + # Find first visible item + for i, item in enumerate(self._items): + if item.rect and item.rect.y + item.rect.height >= visible_top: + start_idx = max(0, i - 1) + break + + # Find last visible item + for i in range(start_idx, len(self._items)): + item = self._items[i] + if item.rect and item.rect.y > visible_bottom: + end_idx = min(len(self._items), i + 2) + break + + self._visible_range = (start_idx, end_idx) + + 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 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.right_item and item.rect: + 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 handled by right item, don't process main item click + return + + # Toggle description visibility if item has description + if item.description: + item.description_visible = not item.description_visible + # Force layout update when description visibility changes + self._last_dim = (0, 0) + + # Call item callback + if item.callback: + item.callback() + + +# Factory functions +def simple_item(title: str, callback: Callable | None = None) -> ListItem: + return ListItem(title=title, callback=callback) + + +def toggle_item( + title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "" +) -> ListItem: + toggle = ToggleRightItem(initial_state=initial_state) + return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback) + + +def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem: + button = ButtonRightItem(text=button_text) + return ListItem(title=title, description=description, right_item=button, callback=callback) + + +def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem: + text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255)) + return ListItem(title=title, description=description, right_item=text_item, callback=callback) diff --git a/system/ui/lib/toggle.py b/system/ui/lib/toggle.py index 5ae2500406..0128dca206 100644 --- a/system/ui/lib/toggle.py +++ b/system/ui/lib/toggle.py @@ -9,9 +9,9 @@ ANIMATION_SPEED = 8.0 class Toggle: - def __init__(self, x, y, initial_state=False): + def __init__(self, initial_state=False): self._state = initial_state - self._rect = rl.Rectangle(x, y, WIDTH, HEIGHT) + self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT) self._progress = 1.0 if initial_state else 0.0 self._target = self._progress @@ -20,17 +20,23 @@ class Toggle: if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect): self._state = not self._state self._target = 1.0 if self._state else 0.0 + return 1 + return 0 def get_state(self): return self._state + def set_state(self, state: bool): + self._state = state + def update(self): if abs(self._progress - self._target) > 0.01: delta = rl.get_frame_time() * ANIMATION_SPEED self._progress += delta if self._progress < self._target else -delta self._progress = max(0.0, min(1.0, self._progress)) - def render(self): + def render(self, rect: rl.Rectangle): + self._rect.x, self._rect.y = rect.x, rect.y self. update() # Draw background bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT) @@ -42,15 +48,7 @@ class Toggle: knob_y = self._rect.y + HEIGHT / 2 rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, KNOB_COLOR) + return self.handle_input() + def _blend_color(self, c1, c2, t): return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255) - - -if __name__ == "__main__": - from openpilot.system.ui.lib.application import gui_app - - gui_app.init_window("Text toggle example") - toggle = Toggle(100, 100) - for _ in gui_app.render(): - toggle.handle_input() - toggle.render() diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py new file mode 100644 index 0000000000..fdfb1970aa --- /dev/null +++ b/system/ui/lib/wrap_text.py @@ -0,0 +1,87 @@ +import pyray as rl +from openpilot.system.ui.lib.text_measure import measure_text_cached + +def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]: + if not word: + return [] + + parts = [] + remaining = word + + while remaining: + if measure_text_cached(font, remaining, font_size).x <= max_width: + parts.append(remaining) + break + + # Binary search for the longest substring that fits + left, right = 1, len(remaining) + best_fit = 1 + + while left <= right: + mid = (left + right) // 2 + substring = remaining[:mid] + width = measure_text_cached(font, substring, font_size).x + + if width <= max_width: + best_fit = mid + left = mid + 1 + else: + right = mid - 1 + + # Add the part that fits + parts.append(remaining[:best_fit]) + remaining = remaining[best_fit:] + + return parts + + +def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]: + if not text or max_width <= 0: + return [] + + words = text.split() + if not words: + return [] + + lines: list[str] = [] + current_line: list[str] = [] + current_width = 0 + space_width = int(measure_text_cached(font, " ", font_size).x) + + for word in words: + word_width = int(measure_text_cached(font, word, font_size).x) + + # Check if word alone exceeds max width (need to break the word) + if word_width > max_width: + # Finish current line if it has content + if current_line: + lines.append(" ".join(current_line)) + current_line = [] + current_width = 0 + + # Break the long word into parts + lines.extend(_break_long_word(font, word, font_size, max_width)) + continue + + # Calculate width if we add this word + needed_width = current_width + if current_line: # Need space before word + needed_width += space_width + needed_width += word_width + + # Check if word fits on current line + if needed_width <= max_width: + current_line.append(word) + current_width = needed_width + else: + # Start new line with this word + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + current_width = word_width + + # Add remaining words + if current_line: + lines.append(" ".join(current_line)) + + return lines