Revert "ui: refactor ListView for generic widget support and simplified item architecture" (#35542)

Revert "ui: refactor ListView for generic widget support and simplified item …"

This reverts commit 32ae9efb3d.
pull/35544/head
Shane Smiskol 3 months ago committed by GitHub
parent 5138217673
commit 3a10bdb1e7
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, ToggleItem from openpilot.system.ui.lib.list_view import ListView, toggle_item
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyItem
from openpilot.system.ui.lib.widget import Widget from openpilot.system.ui.lib.widget import Widget
from openpilot.common.params import Params
from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
# Description constants # Description constants
DESCRIPTIONS = { DESCRIPTIONS = {
@ -16,33 +16,34 @@ 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 = [
ToggleItem( toggle_item(
"Enable ADB", "Enable ADB",
DESCRIPTIONS["enable_adb"], description=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,
), ),
SshKeyItem("SSH Key", description=DESCRIPTIONS["ssh_key"]), ssh_key_item("SSH Key", description=DESCRIPTIONS["ssh_key"]),
ToggleItem( toggle_item(
"Joystick Debug Mode", "Joystick Debug Mode",
DESCRIPTIONS["joystick_debug_mode"], description=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,
), ),
ToggleItem( toggle_item(
"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,
), ),
ToggleItem( toggle_item(
"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,
), ),
@ -53,7 +54,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, state): pass def _on_enable_adb(self): pass
def _on_joystick_debug_mode(self, state): pass def _on_joystick_debug_mode(self): pass
def _on_long_maneuver_mode(self, state): pass def _on_long_maneuver_mode(self): pass
def _on_alpha_long_enabled(self, state): pass def _on_alpha_long_enabled(self): 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, TextItem, ButtonItem, DualButtonItem from openpilot.system.ui.lib.list_view import ListView, text_item, button_item, dual_button_item
from openpilot.system.ui.lib.widget import Widget, DialogResult from openpilot.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 = [
TextItem("Dongle ID", dongle_id), text_item("Dongle ID", dongle_id),
TextItem("Serial", serial), text_item("Serial", serial),
ButtonItem("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device), button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device),
ButtonItem("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad), button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad),
ButtonItem("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt), button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt),
ButtonItem("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI), button_item("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI),
ButtonItem("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide),
ButtonItem("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad), button_item("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad),
DualButtonItem("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt), dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt),
] ]
return items return items

@ -115,12 +115,13 @@ 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, y + (button_rect.height - text_size.y) / 2 button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2
) )
rl.draw_text_ex(self._font_medium, panel_info.name, text_pos, 65, 0, text_color) 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, ButtonItem, TextItem from openpilot.system.ui.lib.list_view import ListView, button_item, text_item
from openpilot.system.ui.lib.widget import Widget, DialogResult from openpilot.system.ui.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 = [
TextItem("Current Version", ""), text_item("Current Version", ""),
ButtonItem("Download", "CHECK", callback=self._on_download_update), button_item("Download", "CHECK", callback=self._on_download_update),
ButtonItem("Install Update", "INSTALL", callback=self._on_install_update), button_item("Install Update", "INSTALL", callback=self._on_install_update),
ButtonItem("Target Branch", "SELECT", callback=self._on_select_branch), button_item("Target Branch", "SELECT", callback=self._on_select_branch),
ButtonItem("Uninstall", "UNINSTALL", callback=self._on_uninstall), button_item("Uninstall", "UNINSTALL", callback=self._on_uninstall),
] ]
return items return items

@ -1,4 +1,4 @@
from openpilot.system.ui.lib.list_view import ListView, MultipleButtonItem, ToggleItem from openpilot.system.ui.lib.list_view import ListView, multiple_button_item, toggle_item
from openpilot.system.ui.lib.widget import Widget from openpilot.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 = [
ToggleItem( toggle_item(
"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",
), ),
ToggleItem( toggle_item(
"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",
), ),
ToggleItem( toggle_item(
"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",
), ),
MultipleButtonItem( multiple_button_item(
"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"
), ),
ToggleItem( toggle_item(
"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",
), ),
ToggleItem( toggle_item(
"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",
), ),
ToggleItem( toggle_item(
"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",
), ),
ToggleItem( toggle_item(
"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,8 +8,10 @@ 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,
) )
@ -19,18 +21,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 SshKeyState(Enum): class SshKeyActionState(Enum):
LOADING = "LOADING" LOADING = "LOADING"
ADD = "ADD" ADD = "ADD"
REMOVE = "REMOVE" REMOVE = "REMOVE"
class SshKeyItem(ListItem): class SshKeyAction(ItemAction):
HTTP_TIMEOUT = 15 # seconds HTTP_TIMEOUT = 15 # seconds
MAX_WIDTH = 500 MAX_WIDTH = 500
def __init__(self, title: str, description: str): def __init__(self):
super().__init__(title, description=description) super().__init__(self.MAX_WIDTH, True)
self._keyboard = Keyboard() self._keyboard = Keyboard()
self._params = Params() self._params = Params()
@ -39,14 +41,11 @@ class SshKeyItem(ListItem):
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 = SshKeyState.REMOVE if self._params.get("GithubSshKeys") else SshKeyState.ADD self._state = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD
def render_action(self, rect: rl.Rectangle) -> bool: def _render(self, rect: rl.Rectangle) -> bool:
# Show error dialog if there's an error # 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)
@ -72,8 +71,8 @@ class SshKeyItem(ListItem):
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 != SshKeyState.LOADING, is_enabled=self._state != SshKeyActionState.LOADING,
border_radius=BUTTON_HEIGHT // 2, border_radius=BUTTON_BORDER_RADIUS,
font_size=BUTTON_FONT_SIZE, font_size=BUTTON_FONT_SIZE,
button_style=ButtonStyle.LIST_ACTION, button_style=ButtonStyle.LIST_ACTION,
): ):
@ -82,11 +81,11 @@ class SshKeyItem(ListItem):
return False return False
def _handle_button_click(self): def _handle_button_click(self):
if self._state == SshKeyState.ADD: if self._state == SshKeyActionState.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 == SshKeyState.REMOVE: elif self._state == SshKeyActionState.REMOVE:
self._params.remove("GithubUsername") self._params.remove("GithubUsername")
self._params.remove("GithubSshKeys") self._params.remove("GithubSshKeys")
self._refresh_state() self._refresh_state()
@ -99,7 +98,7 @@ class SshKeyItem(ListItem):
if not username: if not username:
return return
self._state = SshKeyState.LOADING self._state = SshKeyActionState.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):
@ -114,12 +113,16 @@ class SshKeyItem(ListItem):
# 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 = SshKeyState.REMOVE self._state = SshKeyActionState.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 = SshKeyState.ADD self._state = SshKeyActionState.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 = SshKeyState.ADD self._state = SshKeyActionState.ADD
def ssh_key_item(title: str, description: str):
return ListItem(title=title, description=description, action_item=SshKeyAction())

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

@ -43,9 +43,3 @@ 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