tree option dialog + sample

This commit is contained in:
nayan
2025-11-14 20:34:18 -05:00
parent d08f685982
commit a453aa2c51
2 changed files with 434 additions and 1 deletions
@@ -1,6 +1,8 @@
from openpilot.system.ui.sunnypilot.lib.list_view import button_item_sp, toggle_item_sp
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.tree_option_dialog import TreeOptionDialog, TreeNode, TreeFolder
from openpilot.system.ui.lib.application import gui_app
from openpilot.common.params import Params
@@ -23,4 +25,66 @@ class ModelsLayout(Widget):
self._scroller.render(rect)
def _on_model_select(self):
return
# Demo data for TreeOptionDialog
tree_items = [
# Top-level items (no folder)
TreeFolder(
folder="",
items=[
TreeNode("Default Model", "default_model", 0),
TreeNode("Experimental Model", "experimental_model", 1),
]
),
# Models folder
TreeFolder(
folder="Available Models",
items=[
TreeNode("Model A - Comfort", "model_a_comfort", 2),
TreeNode("Model B - Aggressive", "model_b_aggressive", 3),
TreeNode("Model C - Balanced", "model_c_balanced", 4),
TreeNode("Model D - Highway", "model_d_highway", 5),
TreeNode("Model E - City", "model_e_city", 6),
]
),
# Development Models folder
TreeFolder(
folder="Development Models",
items=[
TreeNode("Dev Model Alpha", "dev_alpha", 7),
TreeNode("Dev Model Beta", "dev_beta", 8),
TreeNode("Dev Model Gamma", "dev_gamma", 9),
]
),
# Custom Models folder
TreeFolder(
folder="Custom Models",
items=[
TreeNode("Custom Model 1", "custom_1", 10),
TreeNode("Custom Model 2", "custom_2", 11),
TreeNode("Custom Model 3", "custom_3", 12),
]
),
]
# Get current model selection
current_model = "default_model"
# Create and show the tree dialog
dialog = TreeOptionDialog(
title="Select Model",
items=tree_items,
current=current_model,
fav_param="ModelManager_Favs"
)
# Set as modal overlay
gui_app.set_modal_overlay(dialog)
# Save selection when dialog closes
def save_selection():
if dialog.selection != current_model:
self._params.put("ModelSelection", dialog.selection)
# Update button text or other UI elements here if needed
# Note: In a real implementation, you'd need to hook into the dialog's close event
# to call save_selection(). For now, this demonstrates the usage.
+369
View File
@@ -0,0 +1,369 @@
# /**
# * Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
# *
# * This file is part of sunnypilot and is licensed under the MIT License.
# * See the LICENSE.md file in the root directory for more details.
# */
import pyray as rl
from dataclasses import dataclass
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.common.params import Params
# Constants
MARGIN = 50
TITLE_FONT_SIZE = 70
ITEM_HEIGHT = 135
BUTTON_SPACING = 50
BUTTON_HEIGHT = 160
ITEM_SPACING = 5
STAR_ICON_SIZE = 100
FOLDER_ICON_SIZE = 40
INDENT_SIZE = 50
@dataclass
class TreeNode:
"""Represents a single item in the tree"""
display_name: str
ref: str # Unique identifier for the item
index: int = 0
@dataclass
class TreeFolder:
"""Represents a folder containing tree nodes"""
folder: str # Folder name, empty string for top-level items
items: list[TreeNode]
class TreeItemWidget(Widget):
"""Widget representing a single tree item (folder or node)"""
def __init__(self, text: str, ref: str = "", is_folder: bool = False,
indent_level: int = 0, click_callback=None, favorite_callback=None,
is_favorite: bool = False, star_icon=None, folder_icon=None):
super().__init__()
self.text = text
self.ref = ref
self.is_folder = is_folder
self.indent_level = indent_level
self.is_expanded = False
self.is_selected = False
self.is_favorite = is_favorite
self._click_callback = click_callback
self._favorite_callback = favorite_callback
self.star_icon = star_icon
self.folder_icon = folder_icon
self.children: list[TreeItemWidget] = []
# Fixed height for tree items
self._rect = rl.Rectangle(0, 0, 0, ITEM_HEIGHT)
def add_child(self, child):
self.children.append(child)
def toggle_expand(self):
if self.is_folder:
self.is_expanded = not self.is_expanded
def _render(self, _):
# Background color based on selection state
bg_color = rl.Color(70, 91, 234, 255) if self.is_selected else rl.Color(79, 79, 79, 255)
# Draw background with rounded corners
rl.draw_rectangle_rounded(self._rect, 0.07, 10, bg_color)
# Calculate text position with indent
text_x = self._rect.x + self.indent_level * INDENT_SIZE + 20
text_y = self._rect.y + (self._rect.height - TITLE_FONT_SIZE) / 2
text_width = self._rect.width - self.indent_level * INDENT_SIZE - 40
# Draw folder icon if folder
if self.is_folder and self.folder_icon:
icon_x = text_x
icon_y = self._rect.y + (self._rect.height - FOLDER_ICON_SIZE) / 2
rl.draw_texture_ex(self.folder_icon, rl.Vector2(icon_x, icon_y), 0,
FOLDER_ICON_SIZE / self.folder_icon.width, rl.WHITE)
text_x += FOLDER_ICON_SIZE + 10
text_width -= FOLDER_ICON_SIZE + 10
# Draw text
text_rect = rl.Rectangle(text_x, text_y, text_width - STAR_ICON_SIZE - 20, TITLE_FONT_SIZE)
gui_label(text_rect, self.text, 55, font_weight=FontWeight.LIGHT,
color=rl.WHITE)
# Draw star icon for non-folder items (on the right)
if not self.is_folder and self.star_icon:
star_x = self._rect.x + self._rect.width - STAR_ICON_SIZE - 20
star_y = self._rect.y + (self._rect.height - STAR_ICON_SIZE) / 2
star_rect = rl.Rectangle(star_x, star_y, STAR_ICON_SIZE, STAR_ICON_SIZE)
# Check if star icon is clicked
for mouse_event in gui_app.mouse_events:
if mouse_event.left_released and rl.check_collision_point_rec(mouse_event.pos, star_rect):
if self._favorite_callback:
self._favorite_callback(self)
return
# Draw star icon (filled if favorite, empty otherwise)
icon_scale = STAR_ICON_SIZE / self.star_icon.width
rl.draw_texture_ex(self.star_icon, rl.Vector2(star_x, star_y), 0, icon_scale,
rl.WHITE if self.is_favorite else rl.Color(255, 255, 255, 100))
def _handle_mouse_release(self, mouse_pos):
"""Handle click on the item"""
if self._click_callback:
self._click_callback(self)
return True
class TreeOptionDialog(Widget):
"""Dialog for selecting an item from a hierarchical tree structure with favorites support"""
def __init__(self, title: str, items: list[TreeFolder], current: str = "", fav_param: str = ""):
super().__init__()
self.title = title
self.items = items
self.current = current
self.selection = current
self.fav_param = fav_param
self.params = Params()
# Load favorites from params
self.favorites: set[str] = set()
if fav_param:
fav_str = self.params.get(fav_param)
if fav_str:
self.favorites = set(fav for fav in fav_str.split(';') if fav)
# Load icons
self.star_filled_icon = None
self.star_empty_icon = None
self.folder_icon = None
try:
self.star_filled_icon = rl.load_texture("../../sunnypilot/selfdrive/assets/icons/star-filled.png")
self.star_empty_icon = rl.load_texture("../../sunnypilot/selfdrive/assets/icons/star-empty.png")
self.folder_icon = rl.load_texture("../assets/icons/menu.png")
except:
# Icons are optional
pass
# Build tree structure
self.tree_items: list[TreeItemWidget] = []
self.ref_to_item: dict[str, TreeItemWidget] = {} # Map ref to item for quick lookup
self._build_tree()
# Create scroller with all visible items
self.scroller = Scroller(self._get_visible_items(), spacing=ITEM_SPACING, pad_end=True)
# Buttons
self.cancel_button = Button("Cancel", click_callback=lambda: gui_app.set_modal_overlay(None))
self.select_button = Button("Select", click_callback=self._on_select, button_style=ButtonStyle.PRIMARY)
def _build_tree(self):
"""Build the tree structure from the input items"""
folders: dict[str, TreeItemWidget] = {}
# Create favorites folder
favorites_folder = TreeItemWidget(
"Favorites", "", is_folder=True, indent_level=0,
click_callback=self._on_item_clicked,
folder_icon=self.folder_icon
)
folders["__favorites__"] = favorites_folder
self.tree_items.append(favorites_folder)
# First pass: create all folders and items
for tree_folder in self.items:
folder_name = tree_folder.folder
if not folder_name:
# Top-level items (no folder)
for node in tree_folder.items:
item = self._create_item_widget(node, indent_level=0)
self.tree_items.append(item)
self.ref_to_item[node.ref] = item
else:
# Items in a folder
if folder_name not in folders:
folder_widget = TreeItemWidget(
folder_name, "", is_folder=True, indent_level=0,
click_callback=self._on_item_clicked,
folder_icon=self.folder_icon
)
folders[folder_name] = folder_widget
# Insert after favorites folder
self.tree_items.insert(1, folder_widget)
folder_widget = folders[folder_name]
for node in tree_folder.items:
item = self._create_item_widget(node, indent_level=1)
folder_widget.add_child(item)
self.ref_to_item[node.ref] = item
# Auto-expand folder if it contains the current selection
if node.ref == self.current:
folder_widget.is_expanded = True
# Second pass: populate favorites folder
for fav_ref in self.favorites:
if fav_ref in self.ref_to_item:
original_item = self.ref_to_item[fav_ref]
# Create a duplicate item for the favorites folder
fav_item = self._create_item_widget(
TreeNode(original_item.text, fav_ref, 0),
indent_level=1
)
favorites_folder.add_child(fav_item)
# Auto-expand favorites if it contains the current selection
if fav_ref == self.current:
favorites_folder.is_expanded = True
def _create_item_widget(self, node: TreeNode, indent_level: int) -> TreeItemWidget:
"""Create a tree item widget from a TreeNode"""
is_fav = node.ref in self.favorites
star_icon = self.star_filled_icon if is_fav else self.star_empty_icon
item = TreeItemWidget(
node.display_name, node.ref, is_folder=False, indent_level=indent_level,
click_callback=self._on_item_clicked,
favorite_callback=self._on_favorite_toggled,
is_favorite=is_fav,
star_icon=star_icon
)
if node.ref == self.current:
item.is_selected = True
return item
def _get_visible_items(self) -> list[TreeItemWidget]:
"""Get all currently visible items in the tree (respecting expand/collapse state)"""
visible = []
for item in self.tree_items:
visible.append(item)
if item.is_folder and item.is_expanded:
visible.extend(item.children)
return visible
def _on_item_clicked(self, item: TreeItemWidget):
"""Handle click on a tree item"""
if item.is_folder:
# Toggle folder expansion
item.toggle_expand()
# Rebuild scroller with new visible items
visible_items = self._get_visible_items()
self.scroller._items = visible_items
for widget in visible_items:
widget.set_touch_valid_callback(self.scroller.scroll_panel.is_touch_valid)
else:
# Select the item
# Deselect all items first
for ref_item in self.ref_to_item.values():
ref_item.is_selected = False
# Also deselect favorite duplicates
for tree_item in self.tree_items:
if tree_item.is_folder:
for child in tree_item.children:
child.is_selected = False
# Select the clicked item
item.is_selected = True
self.selection = item.ref
def _on_favorite_toggled(self, item: TreeItemWidget):
"""Handle toggling favorite status of an item"""
if item.ref in self.favorites:
# Remove from favorites
self.favorites.discard(item.ref)
# Update all instances of this item (original + duplicate in favorites)
if item.ref in self.ref_to_item:
self.ref_to_item[item.ref].is_favorite = False
self.ref_to_item[item.ref].star_icon = self.star_empty_icon
# Remove from favorites folder
favorites_folder = self.tree_items[0]
favorites_folder.children = [c for c in favorites_folder.children if c.ref != item.ref]
else:
# Add to favorites
self.favorites.add(item.ref)
# Update all instances of this item
if item.ref in self.ref_to_item:
self.ref_to_item[item.ref].is_favorite = True
self.ref_to_item[item.ref].star_icon = self.star_filled_icon
# Add to favorites folder
favorites_folder = self.tree_items[0]
fav_item = self._create_item_widget(
TreeNode(item.text, item.ref, 0),
indent_level=1
)
# Insert at the beginning of favorites
favorites_folder.children.insert(0, fav_item)
# Save favorites to params
if self.fav_param:
self.params.put(self.fav_param, ';'.join(self.favorites))
# Rebuild scroller with new visible items
visible_items = self._get_visible_items()
self.scroller._items = visible_items
for widget in visible_items:
widget.set_touch_valid_callback(self.scroller.scroll_panel.is_touch_valid)
def _on_select(self):
"""Handle select button click"""
gui_app.set_modal_overlay(None)
def _render(self, rect):
dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN,
rect.width - 2 * MARGIN, rect.height - 2 * MARGIN)
rl.draw_rectangle_rounded(dialog_rect, 0.02, 20, rl.Color(27, 27, 27, 255))
content_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - 2 * MARGIN)
# Title
gui_label(rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, TITLE_FONT_SIZE),
self.title, 70, font_weight=FontWeight.MEDIUM)
# Tree area
tree_y = content_rect.y + TITLE_FONT_SIZE + 25
tree_h = content_rect.height - TITLE_FONT_SIZE - BUTTON_HEIGHT - 60
tree_rect = rl.Rectangle(content_rect.x, tree_y, content_rect.width, tree_h)
# Update all visible items with correct width
for item in self._get_visible_items():
item.set_rect(rl.Rectangle(0, 0, tree_rect.width, ITEM_HEIGHT))
self.scroller.render(tree_rect)
# Buttons
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
button_w = (content_rect.width - BUTTON_SPACING) / 2
cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT)
self.cancel_button.render(cancel_rect)
select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT)
self.select_button.set_enabled(self.selection != self.current)
self.select_button.render(select_rect)
return -1
def show_event(self):
"""Reset scroll position when dialog is shown"""
self.scroller.show_event()
def hide_event(self):
"""Clean up when dialog is hidden"""
self.scroller.hide_event()