mirror of
https://github.com/infiniteCable2/openpilot.git
synced 2026-08-23 01:13:44 +08:00
ui: add ListView component and settings layouts with declarative UI (#35453)
* add flexible ListView component * fix crash --------- Co-authored-by: Shane Smiskol <shane@smiskol.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+11
-13
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user