Files
github-actions[bot] f9fcc7adab sunnypilot v2026.002.000 release
date: 2026-06-28T09:48:35
master commit: da6313dbe95b3f24bb5d8018b0e5f950f5823ca7
2026-06-28 09:49:29 +08:00

297 lines
13 KiB
Python

"""
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.
"""
from dataclasses import dataclass, field
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle, BUTTON_PRESSED_BACKGROUND_COLORS
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.widgets.helpers.fuzzy_search import search_from_list
from openpilot.system.ui.sunnypilot.widgets.helpers.star_icon import draw_star
from openpilot.system.ui.sunnypilot.widgets.input_dialog import InputDialogSP
@dataclass
class TreeNode:
ref: str
data: dict = field(default_factory=dict)
@dataclass
class TreeFolder:
folder: str
nodes: list
class TreeItemWidget(Button):
def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False, is_expanded=False):
super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_padding=20 + indent_level * 30, elide_right=True)
self.text = text
self.ref = ref
self.is_folder = is_folder
self.indent_level = indent_level
self.is_favorite = is_favorite
self.selected = False
self._favorite_callback = favorite_callback
self.text_padding = 20 + indent_level * 30
self.border_radius = 10
self.is_expanded = is_expanded
def _render(self, rect):
indent = 60 * self.indent_level
self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height)
if self.is_pressed:
color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
elif self.selected and self.ref != "search_bar":
color = style.BUTTON_PRIMARY_COLOR
else:
color = style.BUTTON_DISABLED_BG_COLOR
roundness = self.border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, color)
text_offset = self.text_padding + 20 - 15 if self.is_expanded and not self.is_folder and self.indent_level > 0 else self.text_padding + 20
text_rect = rl.Rectangle(self._rect.x + text_offset, self._rect.y, self._rect.width - self.text_padding - 20 - 90, self._rect.height)
self._label.render(text_rect)
if not self.is_folder and self._favorite_callback:
draw_star(self._rect.x + self._rect.width - 90, self._rect.y + self._rect.height / 2, 40, self.is_favorite,
style.ON_BG_COLOR if self.is_favorite else rl.GRAY)
def _handle_mouse_release(self, mouse_pos):
star_rect = rl.Rectangle(self._rect.x + self._rect.width - 90 - 40, self._rect.y + self._rect.height / 2 - 40, 80, 80)
if not self.is_folder and self._favorite_callback and rl.check_collision_point_rec(mouse_pos, star_rect):
self._favorite_callback()
return True
return super()._handle_mouse_release(mouse_pos)
class TreeOptionDialog(MultiOptionDialog):
@property
def on_exit(self):
return self._callback
@on_exit.setter
def on_exit(self, value):
self._callback = value
def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None,
get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None):
super().__init__(title, [], current_ref, option_font_weight)
self.folders = folders
self.selection_ref = current_ref
self.fav_param = fav_param
self.expanded = set()
self.params = Params()
val = self.params.get(fav_param) if fav_param else None
self.favorites = set(val.split(';')) if val else set()
self.query = ""
self.search_prompt = search_prompt or tr("Search")
self.get_folders_fn = get_folders_fn
self.on_exit = on_exit
self.display_func = display_func or (lambda node: node.data.get('display_name', node.ref))
self.search_funcs = search_funcs or [lambda node: node.data.get('display_name', ''), lambda node: node.data.get('short_name', '')]
self._search_rect = None
self._search_width = 0.475
# Default title & overridable subtitle for InputDialogSP
self.search_title = search_title or tr("Enter search query")
self.search_subtitle = search_subtitle
self.search_dialog = None
self._search_pressed = False
self.selection_node = None
# Try to match by ref, by display text, or fall back to "Default" when no ref is set
for folder in self.folders:
for node in folder.nodes:
display = self.display_func(node)
if (
node.ref == current_ref or
display == current_ref or
(not current_ref and node.ref == "Default")
):
self.selection = display
self.current = display
self.selection_node = node
break
if self.selection_node is not None:
break
self._build_visible_items()
def _on_search_confirm(self, result, text):
if result == DialogResult.CONFIRM:
self.query = text
self._build_visible_items()
def _on_search_clicked(self):
self.search_dialog = InputDialogSP(
self.search_title,
self.search_subtitle,
current_text=self.query,
callback=self._on_search_confirm,
)
self.search_dialog.show()
def _toggle_folder(self, folder):
if folder.folder:
if folder.folder in self.expanded:
self.expanded.remove(folder.folder)
else:
self.expanded.add(folder.folder)
if folder == self.folders[-1] and folder.folder in self.expanded:
self.scroller.scroll_panel.set_offset(self.scroller.scroll_panel.offset - 200)
self._build_visible_items(reset_scroll=False)
def _select_node(self, node):
self.selection = self.display_func(node)
self.selection_ref = node.ref
def _toggle_favorite(self, node):
self.favorites.remove(node.ref) if node.ref in self.favorites else self.favorites.add(node.ref)
if self.fav_param:
self.params.put(self.fav_param, ';'.join(self.favorites))
if self.get_folders_fn:
self.folders = self.get_folders_fn(self.favorites)
self._build_visible_items(reset_scroll=False)
def _build_visible_items(self, reset_scroll=True):
self.visible_items = []
# Pinned selected item at the very top (if any)
if getattr(self, "selection_node", None) is not None:
node = self.selection_node
display = self.display_func(node)
self.selection = self.current = display
favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 0,
lambda node_ref=node: self._select_node(node_ref),
favorite_cb, node.ref in self.favorites, is_expanded=True))
for folder in self.folders:
nodes = [node for node in folder.nodes if not self.query or search_from_list(self.query, [search_func(node) for search_func in self.search_funcs])]
if not nodes and self.query:
continue
expanded = folder.folder in self.expanded or not folder.folder or bool(self.query)
if folder.folder:
self.visible_items.append(TreeItemWidget(f"{'-' if expanded else '+'} {folder.folder}", "", True, 0,
lambda folder_ref=folder: self._toggle_folder(folder_ref)))
if expanded:
for node in nodes:
# Skip duplicate root-level item for the selected node
if self.selection_node is not None and node.ref == self.selection_node.ref and not folder.folder:
continue
favorite_cb = (lambda node_ref=node: self._toggle_favorite(node_ref)) if self.fav_param and node.ref != "Default" else None
self.visible_items.append(TreeItemWidget(self.display_func(node), node.ref, False, 1 if folder.folder else 0,
lambda node_ref=node: self._select_node(node_ref),
favorite_cb, node.ref in self.favorites, is_expanded=expanded))
self.option_buttons = self.visible_items
self.options = [item.text for item in self.visible_items]
# Rebuild scroller items to ensure proper setup of touch callbacks
self.scroller._items.clear()
for item in self.option_buttons:
self.scroller.add_widget(item)
if reset_scroll:
self.scroller.scroll_panel.set_offset(0)
def _render(self, rect):
dialog_content_rect = rl.Rectangle(rect.x + 50, rect.y + 50, rect.width - 100, rect.height - 100)
rl.draw_rectangle_rounded(dialog_content_rect, 0.02, 20, rl.BLACK)
# Title on the left
title_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 50, dialog_content_rect.width * 0.5, 70)
gui_label(title_rect, self.title, 70, font_weight=FontWeight.BOLD)
# Search bar on the top right
search_width = dialog_content_rect.width * self._search_width
search_height = 110
search_x = dialog_content_rect.x + dialog_content_rect.width - 50 - search_width
search_y = dialog_content_rect.y + 40 # align roughly with title
self._search_rect = rl.Rectangle(search_x, search_y, search_width, search_height)
# Draw search field
inset = 4
roundness = 0.3
input_rect = rl.Rectangle(self._search_rect.x + inset, self._search_rect.y + inset,
self._search_rect.width - inset * 2, self._search_rect.height - inset * 2)
# Transparent fill (unpressed), white fill (pressed), border
fill_color = style.TREE_DIALOG_SEARCH_BUTTON_PRESSED if self._search_pressed else style.TREE_DIALOG_TRANSPARENT
rl.draw_rectangle_rounded(input_rect, roundness, 10, fill_color)
rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, style.TREE_DIALOG_SEARCH_BUTTON_BORDER)
# Magnifying glass icon
icon_color = rl.Color(180, 180, 180, 240)
cx = input_rect.x + 60
cy = input_rect.y + input_rect.height / 2 - 5
radius = min(input_rect.height * 0.28, 26)
circle_thickness = 4
for i in range(circle_thickness):
rl.draw_circle_lines(int(cx), int(cy), radius - i, icon_color)
handle_thickness = 5
inner_x = cx + radius * 0.65
inner_y = cy + radius * 0.65
outer_x = cx + radius * 1.45
outer_y = cy + radius * 1.45
rl.draw_line_ex(rl.Vector2(inner_x, inner_y), rl.Vector2(outer_x, outer_y), handle_thickness, icon_color)
# User text (query), placed after the icon if present
if self.query:
text_start_x = outer_x + 45
text_rect = rl.Rectangle(text_start_x, input_rect.y, input_rect.x + input_rect.width - text_start_x - 10, input_rect.height)
gui_label(text_rect, self.query, 70, font_weight=FontWeight.MEDIUM)
options_top = self._search_rect.y + self._search_rect.height + 40
options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, options_top, dialog_content_rect.width - 100,
dialog_content_rect.height - (options_top - dialog_content_rect.y) - 210)
for index, option_text in enumerate(self.options):
self.option_buttons[index].selected = (option_text == self.selection)
self.option_buttons[index].set_button_style(ButtonStyle.PRIMARY if option_text == self.selection else ButtonStyle.NORMAL)
self.option_buttons[index].set_rect(rl.Rectangle(0, 0, options_area_rect.width, 135))
self.scroller.render(options_area_rect)
button_width = (dialog_content_rect.width - 150) / 2
button_y_position = dialog_content_rect.y + dialog_content_rect.height - 160
cancel_rect = rl.Rectangle(dialog_content_rect.x + 50, button_y_position, button_width, 160)
self.cancel_button.render(cancel_rect)
select_rect = rl.Rectangle(dialog_content_rect.x + 100 + button_width, button_y_position, button_width, 160)
self.select_button.set_enabled(self.selection != self.current)
self.select_button.render(select_rect)
def _handle_mouse_press(self, mouse_pos):
if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
self._search_pressed = True
return True
return super()._handle_mouse_press(mouse_pos)
def _handle_mouse_release(self, mouse_pos):
clicked_search = False
if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
clicked_search = self._search_pressed
self._search_pressed = False
if clicked_search:
self._on_search_clicked()
return True
return super()._handle_mouse_release(mouse_pos)