mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-08 02:05:43 +08:00
tree option dialog + sample
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user