ui: refactor ListView for generic widget support and simplified item architecture (#35536)

refactor list view

apply reviews
pull/35539/head
Dean Lee 7 days ago committed by GitHub
parent 723a52626d
commit 32ae9efb3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 33
      selfdrive/ui/layouts/settings/developer.py
  2. 20
      selfdrive/ui/layouts/settings/device.py
  3. 3
      selfdrive/ui/layouts/settings/settings.py
  4. 12
      selfdrive/ui/layouts/settings/software.py
  5. 18
      selfdrive/ui/layouts/settings/toggles.py
  6. 37
      selfdrive/ui/widgets/ssh_key.py
  7. 568
      system/ui/lib/list_view.py
  8. 6
      system/ui/lib/widget.py

@ -1,7 +1,7 @@
from openpilot.system.ui.lib.list_view import ListView, toggle_item from openpilot.system.ui.lib.list_view import ListView, ToggleItem
from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params 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 # Description constants
DESCRIPTIONS = { DESCRIPTIONS = {
@ -16,34 +16,33 @@ DESCRIPTIONS = {
), ),
} }
class DeveloperLayout(Widget): class DeveloperLayout(Widget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._params = Params() self._params = Params()
items = [ items = [
toggle_item( ToggleItem(
"Enable ADB", "Enable ADB",
description=DESCRIPTIONS["enable_adb"], DESCRIPTIONS["enable_adb"],
initial_state=self._params.get_bool("AdbEnabled"), initial_state=self._params.get_bool("AdbEnabled"),
callback=self._on_enable_adb, callback=self._on_enable_adb,
), ),
ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]), SshKeyItem("SSH Key", description=DESCRIPTIONS["ssh_key"]),
toggle_item( ToggleItem(
"Joystick Debug Mode", "Joystick Debug Mode",
description=DESCRIPTIONS["joystick_debug_mode"], DESCRIPTIONS["joystick_debug_mode"],
initial_state=self._params.get_bool("JoystickDebugMode"), initial_state=self._params.get_bool("JoystickDebugMode"),
callback=self._on_joystick_debug_mode, callback=self._on_joystick_debug_mode,
), ),
toggle_item( ToggleItem(
"Longitudinal Maneuver Mode", "Longitudinal Maneuver Mode",
description="", "",
initial_state=self._params.get_bool("LongitudinalManeuverMode"), initial_state=self._params.get_bool("LongitudinalManeuverMode"),
callback=self._on_long_maneuver_mode, callback=self._on_long_maneuver_mode,
), ),
toggle_item( ToggleItem(
"openpilot Longitudinal Control (Alpha)", "openpilot Longitudinal Control (Alpha)",
description="", "",
initial_state=self._params.get_bool("AlphaLongitudinalEnabled"), initial_state=self._params.get_bool("AlphaLongitudinalEnabled"),
callback=self._on_alpha_long_enabled, callback=self._on_alpha_long_enabled,
), ),
@ -54,7 +53,7 @@ class DeveloperLayout(Widget):
def _render(self, rect): def _render(self, rect):
self._list_widget.render(rect) self._list_widget.render(rect)
def _on_enable_adb(self): pass def _on_enable_adb(self, state): pass
def _on_joystick_debug_mode(self): pass def _on_joystick_debug_mode(self, state): pass
def _on_long_maneuver_mode(self): pass def _on_long_maneuver_mode(self, state): pass
def _on_alpha_long_enabled(self): pass def _on_alpha_long_enabled(self, state): pass

@ -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.selfdrive.ui.ui_state import ui_state
from openpilot.system.hardware import TICI from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app 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.system.ui.lib.widget import Widget, DialogResult
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
@ -44,15 +44,15 @@ class DeviceLayout(Widget):
serial = self._params.get("HardwareSerial") or "N/A" serial = self._params.get("HardwareSerial") or "N/A"
items = [ items = [
text_item("Dongle ID", dongle_id), TextItem("Dongle ID", dongle_id),
text_item("Serial", serial), TextItem("Serial", serial),
button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device), ButtonItem("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), ButtonItem("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), ButtonItem("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt),
button_item("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI), ButtonItem("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI),
button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), ButtonItem("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), ButtonItem("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), DualButtonItem("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt),
] ]
return items return items

@ -115,13 +115,12 @@ class SettingsLayout(Widget):
# Draw button text (right-aligned) # Draw button text (right-aligned)
text_size = measure_text_cached(self._font_medium, panel_info.name, 65) text_size = measure_text_cached(self._font_medium, panel_info.name, 65)
text_pos = rl.Vector2( 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) rl.draw_text_ex(self._font_medium, panel_info.name, text_pos, 65, 0, text_color)
# Store button rect for click detection # Store button rect for click detection
panel_info.button_rect = button_rect panel_info.button_rect = button_rect
y += NAV_BTN_HEIGHT + button_spacing y += NAV_BTN_HEIGHT + button_spacing
def _draw_current_panel(self, rect: rl.Rectangle): def _draw_current_panel(self, rect: rl.Rectangle):

@ -1,6 +1,6 @@
from openpilot.common.params import Params from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app 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.lib.widget import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog
@ -15,11 +15,11 @@ class SoftwareLayout(Widget):
def _init_items(self): def _init_items(self):
items = [ items = [
text_item("Current Version", ""), TextItem("Current Version", ""),
button_item("Download", "CHECK", callback=self._on_download_update), ButtonItem("Download", "CHECK", callback=self._on_download_update),
button_item("Install Update", "INSTALL", callback=self._on_install_update), ButtonItem("Install Update", "INSTALL", callback=self._on_install_update),
button_item("Target Branch", "SELECT", callback=self._on_select_branch), ButtonItem("Target Branch", "SELECT", callback=self._on_select_branch),
button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall), ButtonItem("Uninstall", "UNINSTALL", callback=self._on_uninstall),
] ]
return items return items

@ -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.system.ui.lib.widget import Widget
from openpilot.common.params import Params from openpilot.common.params import Params
@ -29,24 +29,24 @@ class TogglesLayout(Widget):
super().__init__() super().__init__()
self._params = Params() self._params = Params()
items = [ items = [
toggle_item( ToggleItem(
"Enable openpilot", "Enable openpilot",
DESCRIPTIONS["OpenpilotEnabledToggle"], DESCRIPTIONS["OpenpilotEnabledToggle"],
self._params.get_bool("OpenpilotEnabledToggle"), self._params.get_bool("OpenpilotEnabledToggle"),
icon="chffr_wheel.png", icon="chffr_wheel.png",
), ),
toggle_item( ToggleItem(
"Experimental Mode", "Experimental Mode",
initial_state=self._params.get_bool("ExperimentalMode"), initial_state=self._params.get_bool("ExperimentalMode"),
icon="experimental_white.png", icon="experimental_white.png",
), ),
toggle_item( ToggleItem(
"Disengage on Accelerator Pedal", "Disengage on Accelerator Pedal",
DESCRIPTIONS["DisengageOnAccelerator"], DESCRIPTIONS["DisengageOnAccelerator"],
self._params.get_bool("DisengageOnAccelerator"), self._params.get_bool("DisengageOnAccelerator"),
icon="disengage_on_accelerator.png", icon="disengage_on_accelerator.png",
), ),
multiple_button_item( MultipleButtonItem(
"Driving Personality", "Driving Personality",
DESCRIPTIONS["LongitudinalPersonality"], DESCRIPTIONS["LongitudinalPersonality"],
buttons=["Aggressive", "Standard", "Relaxed"], buttons=["Aggressive", "Standard", "Relaxed"],
@ -55,25 +55,25 @@ class TogglesLayout(Widget):
selected_index=int(self._params.get("LongitudinalPersonality") or 0), selected_index=int(self._params.get("LongitudinalPersonality") or 0),
icon="speed_limit.png" icon="speed_limit.png"
), ),
toggle_item( ToggleItem(
"Enable Lane Departure Warnings", "Enable Lane Departure Warnings",
DESCRIPTIONS["IsLdwEnabled"], DESCRIPTIONS["IsLdwEnabled"],
self._params.get_bool("IsLdwEnabled"), self._params.get_bool("IsLdwEnabled"),
icon="warning.png", icon="warning.png",
), ),
toggle_item( ToggleItem(
"Always-On Driver Monitoring", "Always-On Driver Monitoring",
DESCRIPTIONS["AlwaysOnDM"], DESCRIPTIONS["AlwaysOnDM"],
self._params.get_bool("AlwaysOnDM"), self._params.get_bool("AlwaysOnDM"),
icon="monitoring.png", icon="monitoring.png",
), ),
toggle_item( ToggleItem(
"Record and Upload Driver Camera", "Record and Upload Driver Camera",
DESCRIPTIONS["RecordFront"], DESCRIPTIONS["RecordFront"],
self._params.get_bool("RecordFront"), self._params.get_bool("RecordFront"),
icon="monitoring.png", icon="monitoring.png",
), ),
toggle_item( ToggleItem(
"Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png" "Use Metric System", DESCRIPTIONS["IsMetric"], self._params.get_bool("IsMetric"), icon="monitoring.png"
), ),
] ]

@ -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.application import gui_app, FontWeight
from openpilot.system.ui.lib.button import gui_button, ButtonStyle from openpilot.system.ui.lib.button import gui_button, ButtonStyle
from openpilot.system.ui.lib.list_view import ( from openpilot.system.ui.lib.list_view import (
ItemAction,
ListItem, ListItem,
BUTTON_HEIGHT, BUTTON_HEIGHT,
BUTTON_BORDER_RADIUS,
BUTTON_FONT_SIZE, BUTTON_FONT_SIZE,
BUTTON_WIDTH, BUTTON_WIDTH,
) )
@ -21,18 +19,18 @@ from openpilot.system.ui.widgets.confirm_dialog import alert_dialog
from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.keyboard import Keyboard
class SshKeyActionState(Enum): class SshKeyState(Enum):
LOADING = "LOADING" LOADING = "LOADING"
ADD = "ADD" ADD = "ADD"
REMOVE = "REMOVE" REMOVE = "REMOVE"
class SshKeyAction(ItemAction): class SshKeyItem(ListItem):
HTTP_TIMEOUT = 15 # seconds HTTP_TIMEOUT = 15 # seconds
MAX_WIDTH = 500 MAX_WIDTH = 500
def __init__(self): def __init__(self, title: str, description: str):
super().__init__(self.MAX_WIDTH, True) super().__init__(title, description=description)
self._keyboard = Keyboard() self._keyboard = Keyboard()
self._params = Params() self._params = Params()
@ -41,11 +39,14 @@ class SshKeyAction(ItemAction):
self._refresh_state() self._refresh_state()
def get_action_width(self) -> int:
return self.MAX_WIDTH
def _refresh_state(self): def _refresh_state(self):
self._username = self._params.get("GithubUsername", "") 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 # Show error dialog if there's an error
if self._error_message: if self._error_message:
message = copy.copy(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 rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT
), ),
self._state.value, self._state.value,
is_enabled=self._state != SshKeyActionState.LOADING, is_enabled=self._state != SshKeyState.LOADING,
border_radius=BUTTON_BORDER_RADIUS, border_radius=BUTTON_HEIGHT // 2,
font_size=BUTTON_FONT_SIZE, font_size=BUTTON_FONT_SIZE,
button_style=ButtonStyle.LIST_ACTION, button_style=ButtonStyle.LIST_ACTION,
): ):
@ -81,11 +82,11 @@ class SshKeyAction(ItemAction):
return False return False
def _handle_button_click(self): def _handle_button_click(self):
if self._state == SshKeyActionState.ADD: if self._state == SshKeyState.ADD:
self._keyboard.clear() self._keyboard.clear()
self._keyboard.set_title("Enter your GitHub username") self._keyboard.set_title("Enter your GitHub username")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_username_submit) 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("GithubUsername")
self._params.remove("GithubSshKeys") self._params.remove("GithubSshKeys")
self._refresh_state() self._refresh_state()
@ -98,7 +99,7 @@ class SshKeyAction(ItemAction):
if not username: if not username:
return return
self._state = SshKeyActionState.LOADING self._state = SshKeyState.LOADING
threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start() threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start()
def _fetch_ssh_key(self, username: str): def _fetch_ssh_key(self, username: str):
@ -113,16 +114,12 @@ class SshKeyAction(ItemAction):
# Success - save keys # Success - save keys
self._params.put("GithubUsername", username) self._params.put("GithubUsername", username)
self._params.put("GithubSshKeys", keys) self._params.put("GithubSshKeys", keys)
self._state = SshKeyActionState.REMOVE self._state = SshKeyState.REMOVE
self._username = username self._username = username
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
self._error_message = "Request timed out" self._error_message = "Request timed out"
self._state = SshKeyActionState.ADD self._state = SshKeyState.ADD
except Exception: except Exception:
self._error_message = f"No SSH keys found for user '{username}'" self._error_message = f"No SSH keys found for user '{username}'"
self._state = SshKeyActionState.ADD self._state = SshKeyState.ADD
def ssh_key_item(title: str, description: str):
return ListItem(title=title, description=description, action_item=SshKeyAction())

@ -1,8 +1,6 @@
import os
import pyray as rl import pyray as rl
from dataclasses import dataclass
from collections.abc import Callable 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.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight 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.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.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from openpilot.system.ui.lib.widget import Widget from openpilot.system.ui.lib.widget import Widget
ITEM_BASE_HEIGHT = 170
LINE_PADDING = 40 LINE_PADDING = 40
LINE_COLOR = rl.GRAY ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20 ITEM_PADDING = 20
ITEM_SPACING = 80
ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE ITEM_TEXT_COLOR = rl.WHITE
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 140 ITEM_DESC_V_OFFSET = 140
RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80 ICON_SIZE = 80
BUTTON_WIDTH = 250 BUTTON_WIDTH = 250
BUTTON_HEIGHT = 100 BUTTON_HEIGHT = 100
BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35 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): if callable(value):
return value() return value()
return value if value is not None else default return value if value is not None else default
# Abstract base class for right-side items class ListItem(Widget, ABC):
class ItemAction(Widget, ABC): def __init__(self, title, description: StrSrc=None, enabled: BoolSrc=True, visible: BoolSrc=True, icon=None):
def __init__(self, width: int = 100, enabled: bool | Callable[[], bool] = True):
super().__init__() super().__init__()
self.width = width self.title = title
self._icon = icon
self.description = description
self.show_desc = False
self._enabled_source = enabled 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 @property
def enabled(self): 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: # Draw icon if present
return self.width 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): @abstractmethod
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True): def get_action_width(self) -> int:
super().__init__(width, enabled) """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.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.set_enabled(self.enabled)
self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)) toggle_rect = rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) // 2,
return False 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): def set_state(self, state: bool):
self.state = state
self.toggle.set_state(state) self.toggle.set_state(state)
def get_state(self) -> bool: def get_state(self):
return self.state return self.toggle.get_state()
class ButtonAction(ItemAction): class ButtonItem(ListItem):
def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): def __init__(self, title: str, button_text, description=None, callback=None, **kwargs):
super().__init__(width, enabled) super().__init__(title, description, **kwargs)
self._text_source = text self._button_text_src = button_text
self._callback = callback
@property def get_button_text(self):
def text(self): return _get_value(self._button_text_src, "Error")
return _resolve_value(self._text_source, "Error")
def _render(self, rect: rl.Rectangle) -> bool:
return gui_button(
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
self.text,
border_radius=BUTTON_BORDER_RADIUS,
font_weight=BUTTON_FONT_WEIGHT,
font_size=BUTTON_FONT_SIZE,
button_style=ButtonStyle.LIST_ACTION,
is_enabled=self.enabled,
) == 1
class TextAction(ItemAction):
def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
self._text_source = text
self.color = color
self._font = gui_app.font(FontWeight.NORMAL) def get_action_width(self) -> int:
initial_text = _resolve_value(text, "") return BUTTON_WIDTH
text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x
super().__init__(int(text_width + TEXT_PADDING), enabled)
@property def render_action(self, rect: rl.Rectangle):
def text(self): button_rect = rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) // 2, BUTTON_WIDTH, BUTTON_HEIGHT)
return _resolve_value(self._text_source, "Error") 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 class TextItem(ListItem):
text_y = rect.y + (rect.height - text_size.y) / 2 def __init__(self, title: str, value: str | Callable[[], str], **kwargs):
rl.draw_text_ex(self._font, current_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, self.color) super().__init__(title, **kwargs)
return False 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: def render_action(self, rect: rl.Rectangle):
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x value = self.get_value()
return int(text_width + TEXT_PADDING) 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): class DualButtonItem(Widget):
def __init__(self, left_text: str, right_text: str, left_callback: Callable = None, def __init__(self, left_text: str, right_text: str, left_callback: Callable, right_callback: Callable):
right_callback: Callable = None, enabled: bool | Callable[[], bool] = True): super().__init__()
super().__init__(width=0, enabled=enabled) # Width 0 means use full width self.left_text = left_text
self.left_text, self.right_text = left_text, right_text self.right_text = right_text
self.left_callback, self.right_callback = left_callback, right_callback 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: def _render(self, rect: rl.Rectangle):
button_spacing = 30 button_width = (rect.width - self._button_spacing) / 2
button_height = 120 button_y = rect.y + (rect.height - self._button_height) / 2
button_width = (rect.width - button_spacing) / 2
button_y = rect.y + (rect.height - button_height) / 2
left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height) left_rect = rl.Rectangle(rect.x, button_y, button_width, self._button_height)
right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, 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 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) == 1 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() self.left_callback()
return True if right_clicked and self.right_callback is not None:
if right_clicked and self.right_callback:
self.right_callback() self.right_callback()
return True
return False
class MultipleButtonAction(ItemAction): class MultipleButtonItem(ListItem):
def __init__(self, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None): def __init__(self, title: str, description: str, buttons: list[str], button_width: int, selected_index: int = 0, callback: Callable = None, **kwargs):
super().__init__(width=len(buttons) * (button_width + 20), enabled=True) super().__init__(title, description, **kwargs)
self.buttons = buttons self.buttons = buttons
self.button_width = button_width self.button_width = button_width
self.selected_button = selected_index self.selected_index = selected_index
self.callback = callback self.callback = callback
self._font = gui_app.font(FontWeight.MEDIUM) self._font = gui_app.font(FontWeight.MEDIUM)
self._colors = {
def _render(self, rect: rl.Rectangle) -> bool: '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 spacing = 20
button_y = rect.y + (rect.height - 100) / 2 button_y = rect.y + (rect.height - 100) / 2
clicked = -1 clicked = -1
@ -171,15 +284,13 @@ class MultipleButtonAction(ItemAction):
mouse_pos = rl.get_mouse_position() mouse_pos = rl.get_mouse_position()
is_hovered = rl.check_collision_point_rec(mouse_pos, button_rect) 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_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 bg_color = (self._colors['disabled'] if not self.enabled and is_selected else
if is_selected: self._colors['selected'] if is_selected else
bg_color = rl.Color(51, 171, 76, 255) # Green self._colors['hovered'] if is_pressed and self.enabled else
elif is_pressed: self._colors['normal'])
bg_color = rl.Color(74, 74, 74, 255) # Dark gray text_color = self._colors['text_disabled'] if not self.enabled else self._colors['text']
else:
bg_color = rl.Color(57, 57, 57, 255) # Gray
# Draw button # Draw button
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) 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_size = measure_text_cached(self._font, text, 40)
text_x = button_x + (self.button_width - text_size.x) / 2 text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (100 - text_size.y) / 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 # Handle click only if enabled
if is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT): if self.enabled and is_hovered and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
clicked = i clicked = i
if clicked >= 0: if clicked >= 0:
self.selected_button = clicked self.selected_index = clicked
if self.callback: if self.callback:
self.callback(clicked) self.callback(clicked)
return True return True
return False 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): class ListView(Widget):
def __init__(self, items: list[ListItem]): def __init__(self, items: list[ListItem]):
super().__init__() super().__init__()
self._items = items self.items = items
self.scroll_panel = GuiScrollPanel() self.scroll_panel = GuiScrollPanel()
self._font = gui_app.font(FontWeight.NORMAL)
self._hovered_item = -1
def _render(self, rect: rl.Rectangle): 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) content_rect = rl.Rectangle(rect.x, rect.y, rect.width, total_height)
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect) 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 # Set scissor mode for clipping
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) 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: if not item.is_visible:
continue continue
y = int(item.rect.y + scroll_offset.y) item_height = item._get_height(int(rect.width))
if y + item.rect.height <= rect.y or y >= rect.y + rect.height:
continue
self._render_item(item, y)
# Draw separator line
next_visible_item = self._get_next_visible_item(i)
if next_visible_item is not None:
line_y = int(y + item.rect.height - 1)
rl.draw_line(
int(item.rect.x) + LINE_PADDING,
line_y,
int(item.rect.x + item.rect.width) - LINE_PADDING * 2,
line_y,
LINE_COLOR,
)
rl.end_scissor_mode() # Skip if outside viewport
if y + item_height < rect.y or y > rect.y + rect.height:
def _get_next_visible_item(self, current_index: int) -> int | None: y += item_height
for i in range(current_index + 1, len(self._items)):
if self._items[i].is_visible:
return i
return None
def _update_item_rects(self, container_rect: rl.Rectangle) -> float:
current_y = 0.0
for item in self._items:
if not item.is_visible:
item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, 0)
continue
content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2))
item_height = item.get_item_height(self._font, content_width)
item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height)
current_y += item_height
return current_y # total height of all items
def _render_item(self, item: ListItem, y: int):
content_x = item.rect.x + ITEM_PADDING
text_x = content_x
# Only draw title and icon for items that have them
if item.title:
# Draw icon if present
if item.icon:
icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE)
rl.draw_texture(icon_texture, int(content_x), int(y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
text_size = measure_text_cached(self._font, item.title, ITEM_TEXT_FONT_SIZE)
item_y = y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible
current_description = item.get_description()
if item.description_visible and current_description and item._wrapped_description:
rl.draw_text_ex(
self._font,
item._wrapped_description,
rl.Vector2(text_x, y + ITEM_DESC_V_OFFSET),
ITEM_DESC_FONT_SIZE,
0,
ITEM_DESC_TEXT_COLOR,
)
# Draw right item if present
if item.action_item:
right_rect = item.get_right_item_rect(item.rect)
right_rect.y = y
if item.action_item.render(right_rect) and item.action_item.enabled:
# Right item was clicked/activated
if item.callback:
item.callback()
def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
mouse_pos = rl.get_mouse_position()
self._hovered_item = -1
if not rl.check_collision_point_rec(mouse_pos, rect):
return
content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y
for i, item in enumerate(self._items):
if not item.is_visible:
continue continue
if item.rect: # Render item
# Check if mouse is within this item's bounds in content space item.render(rl.Rectangle(rect.x, y, rect.width, item_height))
if (
mouse_pos.x >= rect.x
and mouse_pos.x <= rect.x + rect.width
and content_mouse_y >= item.rect.y
and content_mouse_y <= item.rect.y + item.rect.height
):
item_screen_y = item.rect.y + scroll_offset.y
if item_screen_y < rect.height and item_screen_y + item.rect.height > 0:
self._hovered_item = i
break
# Handle click on main item (not right item)
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0:
item = self._items[self._hovered_item]
# Check if click was on right item area
if item.action_item and item.rect:
# Use the same coordinate system as in _render_item
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
right_rect = item.get_right_item_rect(adjusted_rect)
if rl.check_collision_point_rec(mouse_pos, right_rect):
# Click was on right item, don't toggle description
return
# Toggle description visibility if item has description # Draw separator line
if item.description: if i < len(self.items) - 1:
item.description_visible = not item.description_visible 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)
# 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)
y += item_height
def multiple_button_item(title: str, description: str, buttons: list[str], selected_index: int, rl.end_scissor_mode()
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)

@ -36,3 +36,9 @@ class Widget(abc.ABC):
def _handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool: def _handle_mouse_release(self, mouse_pos: rl.Vector2) -> bool:
"""Handle mouse release events, if applicable.""" """Handle mouse release events, if applicable."""
return False 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")

Loading…
Cancel
Save