sunnypilot v2026.002.000

version: sunnypilot v2026.002.000 (staging)
date: 2026-07-21T07:44:02
master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
github-actions[bot] committed 2026-07-21 07:44:02 +00:00
commit de77c89cf6
3995 files changed
+1260688

No files matched your search

@@ -0,0 +1,18 @@
"""
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.
"""
def get_highlighted_description(params, param_name: str, descriptions: list[str]) -> str:
index = int(params.get(param_name, return_default=True))
lines = []
for i, desc in enumerate(descriptions):
if i == index:
lines.append(f"<b>{desc}</b>")
else:
lines.append(f"{desc}")
return "<br>".join(lines)
Whitespace-only changes.
@@ -0,0 +1,40 @@
"""
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 re
import unicodedata
def normalize(text: str) -> str:
return unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('utf-8').lower()
def search_from_list(query: str, items: list[str]) -> list[str]:
if not query:
return items
normalized_query = normalize(query)
search_terms = [re.sub(r'[^a-z0-9]', '', term) for term in normalized_query.split() if term.strip()]
results = []
for item in items:
normalized_item = normalize(item)
item_with_spaces = re.sub(r'[^a-z0-9\s]', ' ', normalized_item)
item_stripped = re.sub(r'[^a-z0-9]', '', normalized_item)
all_terms_match = True
for term in search_terms:
if not term:
continue
if term not in item_with_spaces and term not in item_stripped:
all_terms_match = False
break
if all_terms_match:
results.append(item)
return results
@@ -0,0 +1,26 @@
"""
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 math
import pyray as rl
def draw_star(center_x, center_y, radius, is_filled, color):
center = rl.Vector2(center_x, center_y)
points = []
for i in range(10):
angle = -(i * 36 + 18) * math.pi / 180
r = radius if i % 2 == 0 else radius / 2
x = center_x + r * math.cos(angle)
y = center_y + r * math.sin(angle)
points.append(rl.Vector2(x, y))
for i in range(10):
if is_filled:
rl.draw_triangle(center, points[i], points[(i + 1) % 10], color)
rl.draw_line_ex(points[i], points[(i + 1) % 10], 2, color)
@@ -0,0 +1,27 @@
"""
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 openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.html_render import HtmlModal
class HtmlModalSP(HtmlModal):
def __init__(self, file_path=None, text=None, callback=None):
super().__init__(file_path=file_path, text=text)
self._callback = callback
self._dialog_result = DialogResult.NO_ACTION
self._ok_button._click_callback = self._on_ok_clicked
def _on_ok_clicked(self):
self._dialog_result = DialogResult.CONFIRM
gui_app.pop_widget()
if self._callback:
self._callback(self._dialog_result)
def reset(self):
self._dialog_result = DialogResult.NO_ACTION
@@ -0,0 +1,43 @@
"""
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 collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
class InputDialogSP:
def __init__(self, title: str, sub_title: str | None = None, current_text: str = "", param: str | None = None,
callback: Callable[[DialogResult, str], None] | None = None,
min_text_size: int = 0, password_mode: bool = False):
self.callback = callback
self.current_text = current_text
self.keyboard = Keyboard(max_text_size=255, min_text_size=min_text_size, password_mode=password_mode)
self.param = param
self._params = Params()
self.sub_title = sub_title
self.title = title
def show(self):
self.keyboard.reset(min_text_size=self.keyboard._min_text_size)
if self.sub_title:
self.keyboard.set_title(self.title, self.sub_title)
else:
self.keyboard.set_title(self.title)
self.keyboard.set_text(self.current_text)
def internal_callback(result: DialogResult):
text = self.keyboard.text if result == DialogResult.CONFIRM else ""
if result == DialogResult.CONFIRM and self.param:
self._params.put(self.param, text)
if self.callback:
self.callback(result, text)
self.keyboard.set_callback(internal_callback)
gui_app.push_widget(self.keyboard)
@@ -0,0 +1,411 @@
"""
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 collections.abc import Callable
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.sunnypilot.widgets.toggle import ToggleSP
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.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \
_resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, DualButtonAction
from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, LABEL_WIDTH
class Spacer(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.x + self._rect.width), int(self._rect.y), rl.Color(0,0,0,0))
class ToggleActionSP(ToggleAction):
def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
callback: Callable[[bool], None] | None = None, param: str | None = None):
ToggleAction.__init__(self, initial_state, width, enabled, callback)
self.toggle = ToggleSP(initial_state=initial_state, callback=callback, param=param)
class ButtonSP(Button):
def _update_state(self):
super()._update_state()
if self.enabled:
if self.is_pressed:
self._background_color = style.BUTTON_OFF_PRESSED
else:
self._background_color = style.BUTTON_ENABLED_OFF
else:
self._background_color = style.BUTTON_DISABLED
self._label.set_text_color(style.BUTTON_TEXT_DISABLED)
class SimpleButtonActionSP(ItemAction):
def __init__(self, button_text: str | Callable[[], str], callback: Callable | None = None,
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH):
super().__init__(width=button_width, enabled=enabled)
self.button_action = ButtonSP(button_text, click_callback=callback, button_style=ButtonStyle.NORMAL,
border_radius=20)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.button_action.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle) -> bool | int | None:
self.button_action.set_enabled(self.enabled)
return self.button_action.render(rect)
class ButtonActionSP(ButtonAction):
def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_ACTION_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(text=text, width=width, enabled=enabled)
self._value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
self._value_source = value
self._value_color = color
def _render(self, rect: rl.Rectangle) -> bool:
"""Duplicate of ButtonAction._render, with additional value rendering"""
self._button.set_text(self.text)
self._button.set_enabled(_resolve_value(self.enabled))
button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT)
self._button.render(button_rect)
value_text = self.value
if value_text:
value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)
gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._value_color,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
pressed = self._pressed
self._pressed = False
return pressed
class DualButtonActionSP(DualButtonAction):
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None,
right_callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, border_radius: int = 15):
DualButtonAction.__init__(self, left_text, right_text, left_callback, right_callback, enabled)
self.left_button._border_radius = self.right_button._border_radius = border_radius
def _render(self, rect: rl.Rectangle):
button_spacing = 20
button_height = 150
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)
right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
# expand one to full width if other is not visible
if not self.left_button.is_visible:
right_rect.x = rect.x
right_rect.width = rect.width
elif not self.right_button.is_visible:
left_rect.width = rect.width
# Render buttons
self.left_button.render(left_rect)
self.right_button.render(right_rect)
class MultipleButtonActionSP(MultipleButtonAction):
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None,
param: str | None = None):
MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback)
self.param_key = param
self.params = Params()
if self.param_key:
self.selected_button = int(self.params.get(self.param_key, return_default=True))
self._anim_x: float | None = None
self.enabled_buttons: set[int] | None = None
def set_enabled_buttons(self, indices: set[int] | None):
self.enabled_buttons = indices
def _render(self, rect: rl.Rectangle):
button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2
total_width = len(self.buttons) * self.button_width
track_rect = rl.Rectangle(rect.x, button_y, total_width, style.BUTTON_HEIGHT)
bg_color = style.MBC_TRANSPARENT
text_color = style.ITEM_TEXT_COLOR if self.enabled else style.MBC_DISABLED
highlight_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED
# background
rl.draw_rectangle_rounded(track_rect, 0.2, 20, bg_color)
# border
border_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED
rl.draw_rectangle_rounded_lines_ex(track_rect, 0.2, 20, 2, border_color)
# highlight with animation
target_x = rect.x + self.selected_button * self.button_width
if not self._anim_x:
self._anim_x = target_x
self._anim_x += (target_x - self._anim_x) * 0.2
highlight_rect = rl.Rectangle(self._anim_x, button_y, self.button_width, style.BUTTON_HEIGHT)
rl.draw_rectangle_rounded(highlight_rect, 0.2, 20, highlight_color)
# text
for i, _text in enumerate(self.buttons):
button_x = rect.x + i * self.button_width
text = _resolve_value(_text, "")
text_size = measure_text_cached(self._font, text, 40)
text_x = button_x + (self.button_width - text_size.x) / 2
text_y = button_y + (style.BUTTON_HEIGHT - text_size.y) / 2
# Check individual button enabled state
is_button_enabled = self.enabled and (self.enabled_buttons is None or i in self.enabled_buttons)
current_text_color = text_color if is_button_enabled else style.MBC_DISABLED
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, current_text_color)
def _handle_mouse_release(self, mouse_pos: MousePos):
# Override parent method to check individual button enabled state
if not self.enabled:
return
button_y = self._rect.y + (self._rect.height - style.BUTTON_HEIGHT) / 2
for i, _ in enumerate(self.buttons):
button_x = self._rect.x + i * self.button_width
button_rect = rl.Rectangle(button_x, button_y, self.button_width, style.BUTTON_HEIGHT)
if rl.check_collision_point_rec(mouse_pos, button_rect):
# Check if this specific button is enabled
if self.enabled_buttons is not None and i not in self.enabled_buttons:
return
self.selected_button = i
if self.callback:
self.callback(i)
if self.param_key:
self.params.put(self.param_key, self.selected_button)
class ListItemSP(ListItem):
def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
description_visible: bool = False, callback: Callable | None = None,
action_item: ItemAction | None = None, inline: bool = True, title_color: rl.Color = style.ITEM_TEXT_COLOR):
ListItem.__init__(self, title, icon, description, description_visible, callback, action_item)
self.title_color = title_color
self.inline = inline
if not self.inline:
self._rect.height += style.ITEM_BASE_HEIGHT/1.75
self._right_value_source: str | Callable[[], str] | None = None
self._right_value_font = gui_app.font(FontWeight.NORMAL)
self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
def set_title(self, title: str | Callable[[], str] = ""):
self._title = title
def set_right_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
self._right_value_source = value
self._right_value_color = color
@property
def right_value(self) -> str:
if self._right_value_source is None:
return ""
return str(_resolve_value(self._right_value_source, ""))
def _update_state(self):
prev_desc = self._prev_description
super()._update_state()
if self.description_visible and self._prev_description != prev_desc:
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
self._rect.height = self.get_item_height(self._font, content_width)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
if self.description_visible:
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
self._rect.height = self.get_item_height(self._font, content_width)
def get_item_height(self, font: rl.Font, max_width: int) -> float:
height = super().get_item_height(font, max_width)
if self.description_visible:
height += style.ITEM_PADDING * 1.5
if not self.inline:
height += style.ITEM_BASE_HEIGHT / 1.75
return height
def show_description(self, show: bool):
self._set_description_visible(show)
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
if not self.inline:
text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
action_y = item_rect.y + text_size.y + style.ITEM_PADDING * 3
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT)
right_width = self.action_item.get_width_hint()
if right_width == 0:
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
content_width = item_rect.width - (style.ITEM_PADDING * 2)
title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x
right_width = min(content_width - title_width, right_width)
if isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, SimpleButtonActionSP):
action_x = item_rect.x
else:
action_x = item_rect.x + item_rect.width - right_width
action_y = item_rect.y
return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT)
def _render(self, _):
if not self.is_visible:
return
# Don't draw items that are not in parent's viewport
if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height):
return
content_x = self._rect.x + style.ITEM_PADDING
text_x = content_x
left_action_item = isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, SimpleButtonActionSP)
if left_action_item:
item_height = style.SIMPLE_BUTTON_HEIGHT if isinstance(self.action_item, SimpleButtonActionSP) else style.TOGGLE_HEIGHT
left_rect = rl.Rectangle(
content_x,
self._rect.y + (style.ITEM_BASE_HEIGHT - item_height) // 2,
self.action_item.rect.width,
item_height
)
text_x = left_rect.x + left_rect.width + style.ITEM_PADDING * 1.5
# Draw title
if self.title:
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
value_text = self.right_value
if value_text:
# area from after the title to the right edge of the row
value_rect = rl.Rectangle(
text_x, # start at the beginning of the text area
self._rect.y,
self._rect.width - (text_x - self._rect.x) - style.ITEM_PADDING,
style.ITEM_BASE_HEIGHT,
)
if value_rect.width > 0:
gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# Render toggle and handle callback
if self.action_item.render(left_rect) and self.action_item.enabled:
if self.callback:
self.callback()
else:
if self.title:
# Draw main text
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
# Draw right item if present
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if self.callback:
self.callback()
# Draw description if visible
if self.description_visible:
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
description_height = self._html_renderer.get_total_height(content_width)
desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET
if not self.inline and self.action_item:
desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 0.5
description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height)
self._html_renderer.render(description_rect)
def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callable | None = None,
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH) -> ListItemSP:
action = SimpleButtonActionSP(button_text=button_text, enabled=enabled, callback=callback, button_width=button_width)
return ListItemSP(title="", callback=callback, description="", action_item=action)
def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP:
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
return ListItemSP(title=title, description=description, action_item=action, icon=icon)
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable | None = None,
icon: str = "", param: str | None = None, inline: bool = False) -> ListItemSP:
action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param)
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)
def option_item_sp(title: str | Callable[[], str], param: str,
min_value: int, max_value: int, description: str | Callable[[], str] | None = None,
value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None,
enabled: bool | Callable[[], bool] = True,
icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None,
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None, inline: bool = False) -> ListItemSP:
action = OptionControlSP(
param, min_value, max_value, value_change_step,
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
)
return ListItemSP(title=title, description=description, action_item=action, icon=icon, inline=inline)
def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP:
action = ButtonActionSP(text=button_text, enabled=enabled)
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
def dual_button_item_sp(left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None,
right_callback: Callable | None = None, description: str | Callable[[], str] | None = None,
enabled: bool | Callable[[], bool] = True, border_radius: int = 15) -> ListItemSP:
action = DualButtonActionSP(left_text, right_text, left_callback, right_callback, enabled, border_radius)
return ListItemSP(title="", description=description, action_item=action)
class LineSeparatorSP(LineSeparator):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def _render(self, _):
line_y = int(self._rect.y + self._rect.height // 2)
rl.draw_line(int(self._rect.x) + LINE_PADDING, line_y,
int(self._rect.x + self._rect.width) - LINE_PADDING, line_y,
LINE_COLOR)
@@ -0,0 +1,168 @@
import pyray as rl
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.widgets.list_view import ItemAction
# Dimensions and styling constants
BUTTON_WIDTH = 150
BUTTON_HEIGHT = 150
LABEL_WIDTH = 350
BUTTON_SPACING = 25
VALUE_FONT_SIZE = 50
BUTTON_FONT_SIZE = 60
CONTAINER_PADDING = 20
class OptionControlSP(ItemAction):
def __init__(self, param: str, min_value: int, max_value: int,
value_change_step: int = 1, enabled: bool | Callable[[], bool] = True,
on_value_changed: Callable[[int], None] | None = None,
value_map: dict[int, int] | None = None,
label_width: int = LABEL_WIDTH,
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None):
super().__init__(enabled=enabled)
self.params = Params()
self.param_key = param
self.min_value = min_value
self.max_value = max_value
self.value_change_step = value_change_step
self._minus_enabled = enabled
self._plus_enabled = enabled
self.on_value_changed = on_value_changed
self.value_map = value_map
self.label_width = label_width
self.use_float_scaling = use_float_scaling
self.current_value = min_value
self.label_callback = label_callback
if self.value_map:
for key in self.value_map:
if self.value_map[key] == self.params.get(self.param_key, return_default=True):
self.current_value = int(key)
break
else:
value = self.params.get(self.param_key, return_default=True)
self.current_value = int(float(value) * 100.0) if self.use_float_scaling else int(value)
# Initialize font and button styles
self._font = gui_app.font(FontWeight.MEDIUM)
# Layout rectangles for components
self.minus_btn_rect = rl.Rectangle(0, 0, 0, 0)
self.plus_btn_rect = rl.Rectangle(0, 0, 0, 0)
def get_value(self) -> int:
"""Get the current value of the control"""
return self.current_value
def set_value(self, value: int):
"""Set the control to a specific value"""
if not (self.min_value <= value <= self.max_value):
return
if value == self.current_value:
return
self.current_value = value
if self.value_map:
self.params.put(self.param_key, self.value_map[value])
elif self.use_float_scaling:
self.params.put(self.param_key, value / 100.0)
else:
self.params.put(self.param_key, value)
if self.on_value_changed:
self.on_value_changed(value)
def get_displayed_value(self) -> str:
"""Get the displayed value, handling value mapping if present"""
value = self.current_value
if callable(self.label_callback):
if self.value_map:
return self.label_callback(self.value_map[value])
else:
return self.label_callback(value)
if self.value_map:
# Use the value map to get the display string
if value in self.value_map:
return str(self.value_map[value]) # Return the display string
# If using float scaling, format as float
if self.use_float_scaling:
return f"{value / 100.0:.2f}"
return str(value)
def _render(self, rect: rl.Rectangle):
if self._rect.width == 0 or self._rect.height == 0 or not self.is_visible:
return
control_width = (BUTTON_WIDTH * 2) + self.label_width + (BUTTON_SPACING * 2)
total_width = control_width + (CONTAINER_PADDING * 2)
self._rect.width = total_width
start_x = self._rect.x + self._rect.width - control_width - (CONTAINER_PADDING * 2)
component_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
self.container_rect = rl.Rectangle(start_x, component_y, total_width, BUTTON_HEIGHT)
# background
rl.draw_rectangle_rounded(self.container_rect, 0.2, 20, style.OPTION_CONTROL_CONTAINER_BG)
# minus button
self.minus_btn_rect = rl.Rectangle(self.container_rect.x, component_y, BUTTON_WIDTH + CONTAINER_PADDING,
BUTTON_HEIGHT)
# label
label_x = self.container_rect.x + CONTAINER_PADDING + BUTTON_WIDTH + BUTTON_SPACING
self.label_rect = rl.Rectangle(label_x, component_y, self.label_width, BUTTON_HEIGHT)
# plus button
plus_x = label_x + self.label_width + BUTTON_SPACING
self.plus_btn_rect = rl.Rectangle(plus_x, component_y, BUTTON_WIDTH + CONTAINER_PADDING, BUTTON_HEIGHT)
self._minus_enabled = self.enabled and self.current_value > self.min_value
self._plus_enabled = self.enabled and self.current_value < self.max_value
self._render_button(self.minus_btn_rect, "-", self._minus_enabled)
self._render_value_label()
self._render_button(self.plus_btn_rect, "+", self._plus_enabled)
def _render_button(self, rect: rl.Rectangle, text: str, enabled: bool):
mouse_pos = rl.get_mouse_position()
is_pressed = (rl.check_collision_point_rec(mouse_pos, rect) and
self._touch_valid() and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT))
text_color = style.ITEM_TEXT_COLOR if enabled else style.ITEM_DISABLED_TEXT_COLOR
# highlight
if enabled and is_pressed:
rl.draw_rectangle_rounded(rect, 0.2, 20, style.OPTION_CONTROL_BTN_PRESSED)
# button text
text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE)
text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color)
def _render_value_label(self):
"""Render the current value label"""
text = self.get_displayed_value()
text_color = style.ITEM_TEXT_COLOR if self.enabled else style.ITEM_DISABLED_TEXT_COLOR
text_size = measure_text_cached(self._font, text, VALUE_FONT_SIZE)
text_x = self.label_rect.x + (self.label_rect.width - text_size.x) / 2
text_y = self.label_rect.y + (self.label_rect.height - text_size.y) / 2
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), VALUE_FONT_SIZE, 0, text_color)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._minus_enabled and rl.check_collision_point_rec(mouse_pos, self.minus_btn_rect):
new_value = self.current_value - self.value_change_step
new_value = max(self.min_value, new_value)
self.set_value(new_value)
elif self._plus_enabled and rl.check_collision_point_rec(mouse_pos, self.plus_btn_rect):
new_value = self.current_value + self.value_change_step
new_value = min(self.max_value, new_value)
self.set_value(new_value)
@@ -0,0 +1,57 @@
"""
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 openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets.list_view import ListItem, ItemAction
class ProgressBarAction(ItemAction):
def __init__(self, width=600):
super().__init__(width=width)
self.progress = 0.0
self.text = ""
self.show_progress = False
self.text_color = rl.GRAY
self._font = gui_app.font(FontWeight.NORMAL)
def update(self, progress, text, show_progress=False, text_color=rl.GRAY):
self.progress = progress
self.text = text
self.show_progress = show_progress
self.text_color = text_color
def _render(self, rect: rl.Rectangle):
font_size = 40
text_size = measure_text_cached(self._font, self.text, font_size)
padding = 30
bar_width = text_size.x + 2 * padding
text_x = (bar_width - text_size.x) / 2
if self.show_progress and len(parts := self.text.split(' - ', 1)) == 2:
prefix = parts[0]
max_prefix_w = measure_text_cached(self._font, "100%", font_size).x
current_prefix_w = measure_text_cached(self._font, prefix, font_size).x
bar_width = (text_size.x - current_prefix_w + max_prefix_w) + 2 * padding
text_x = padding + (max_prefix_w - current_prefix_w)
bar_height = 60
bar_rect = rl.Rectangle(rect.x + rect.width - bar_width, rect.y + (rect.height - bar_height) / 2, bar_width, bar_height)
if self.show_progress:
inner_rect = rl.Rectangle(bar_rect.x + 4, bar_rect.y + 4, bar_rect.width - 8, bar_rect.height - 8)
if inner_rect.width > 0:
fill_width = max(0, min(inner_rect.width, inner_rect.width * (self.progress / 100.0)))
rl.draw_rectangle_rounded(rl.Rectangle(inner_rect.x, inner_rect.y, fill_width, inner_rect.height), 0.2, 10, rl.Color(30, 121, 232, 255))
rl.draw_text_ex(self._font, self.text, rl.Vector2(bar_rect.x + text_x, bar_rect.y + (bar_height - text_size.y) / 2), font_size, 0, self.text_color)
def progress_item(title):
action = ProgressBarAction()
return ListItem(title=title, action_item=action)
@@ -0,0 +1,139 @@
"""
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 base64
import pyray as rl
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID, API_HOST
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.lib.text_measure import measure_text_cached
class SunnylinkPairingDialog(PairingDialog):
"""Dialog for device pairing with QR code."""
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
def __init__(self, sponsor_pairing: bool = False):
PairingDialog.__init__(self)
self._sponsor_pairing = sponsor_pairing
self._is_paired_prev = ui_state.sunnylink_state.is_paired()
def _get_pairing_url(self) -> str:
qr_string = "https://github.com/sponsors/sunnyhaibin"
if self._sponsor_pairing:
try:
sl_dongle_id = self.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID
token = SunnylinkApi(sl_dongle_id).get_token()
inner_string = f"1|{sl_dongle_id}|{token}"
payload_bytes = base64.b64encode(inner_string.encode('utf-8')).decode('utf-8')
qr_string = f"{API_HOST}/sso?state={payload_bytes}"
except Exception:
cloudlog.exception("Failed to get pairing token")
return qr_string
def _update_state(self):
is_paired = ui_state.sunnylink_state.is_paired()
if not self._is_paired_prev and is_paired:
gui_app.pop_widget()
def _render(self, rect: rl.Rectangle) -> int:
rl.clear_background(rl.Color(224, 224, 224, 255))
self._check_qr_refresh()
margin = 70
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - 2 * margin, rect.height - 2 * margin)
y = content_rect.y
# Close button
close_size = 80
pad = 20
close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2)
self._close_btn.render(close_rect)
y += close_size + 40
# Title
title = tr("Pair your GitHub account") if self._sponsor_pairing else tr("Early Access: Become a sunnypilot Sponsor")
title_font = gui_app.font(FontWeight.NORMAL)
left_width = int(content_rect.width * 0.5 - 15)
title_wrapped = wrap_text(title_font, title, 75, left_width)
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK)
y += len(title_wrapped) * 75 + 60
# Two columns: instructions and QR code
remaining_height = content_rect.height - (y - content_rect.y)
right_width = content_rect.width // 2 - 20
# Instructions
self._render_instructions(rl.Rectangle(content_rect.x, y, left_width, remaining_height))
# QR code
qr_size = min(right_width, content_rect.height) - 40
qr_x = content_rect.x + left_width + 40 + (right_width - qr_size) // 2
qr_y = content_rect.y
self._render_qr_code(rl.Rectangle(qr_x, qr_y, qr_size, qr_size))
return -1
def _render_instructions(self, rect: rl.Rectangle) -> None:
if self._sponsor_pairing:
instructions = [
tr("Scan the QR code to login to your GitHub account"),
tr("Follow the prompts to complete the pairing process"),
tr("Re-enter the \"sunnylink\" panel to verify sponsorship status"),
tr("If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai")
]
else:
instructions = [
tr("Scan the QR code to visit sunnyhaibin's GitHub Sponsors page"),
tr("Choose your sponsorship tier and confirm your support"),
tr("Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues")
]
font = gui_app.font(FontWeight.BOLD)
y = rect.y
for i, text in enumerate(instructions):
circle_radius = 25
circle_x = rect.x + circle_radius + 15
text_x = rect.x + circle_radius * 2 + 40
text_width = rect.width - (circle_radius * 2 + 40)
wrapped = wrap_text(font, text, 47, int(text_width))
text_height = len(wrapped) * 47
circle_y = y + text_height // 2
# Circle and number
rl.draw_circle(int(circle_x), int(circle_y), circle_radius, rl.Color(70, 70, 70, 255))
number = str(i + 1)
number_size = measure_text_cached(font, number, 30)
rl.draw_text_ex(font, number, (int(circle_x - number_size.x // 2), int(circle_y - number_size.y // 2)), 30, 0, rl.WHITE)
# Text
rl.draw_text_ex(font, "\n".join(wrapped), rl.Vector2(text_x, y), 47, 0.0, rl.BLACK)
y += text_height + 50
if __name__ == "__main__":
gui_app.init_window("pairing device")
pairing = SunnylinkPairingDialog(sponsor_pairing=True)
try:
for _ in gui_app.render():
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result != -1:
break
finally:
del pairing
@@ -0,0 +1,63 @@
"""
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 collections.abc import Callable
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import MousePos
from openpilot.system.ui.widgets.toggle import Toggle
from openpilot.system.ui.sunnypilot.lib.styles import style
KNOB_PADDING = 5
KNOB_RADIUS = style.TOGGLE_BG_HEIGHT / 2 - KNOB_PADDING
class ToggleSP(Toggle):
def __init__(self, initial_state=False, callback: Callable[[bool], None] | None = None, param: str | None = None):
self.param_key = param
self.params = Params()
if self.param_key:
initial_state = self.params.get_bool(self.param_key)
Toggle.__init__(self, initial_state, callback)
def set_rect(self, rect: rl.Rectangle):
self._rect = rl.Rectangle(rect.x, rect.y, style.TOGGLE_WIDTH, style.TOGGLE_HEIGHT)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
if self._enabled and self.param_key:
self.params.put_bool(self.param_key, self._state)
def _render(self, rect: rl.Rectangle):
self.update()
self._rect.y -= style.ITEM_PADDING / 2
if self._enabled:
bg_color = self._blend_color(style.TOGGLE_OFF_COLOR, style.TOGGLE_ON_COLOR, self._progress)
knob_color = style.TOGGLE_KNOB_COLOR
else:
bg_color = self._blend_color(style.TOGGLE_DISABLED_OFF_COLOR, style.TOGGLE_DISABLED_ON_COLOR, self._progress)
knob_color = style.TOGGLE_DISABLED_KNOB_COLOR
# Draw background
bg_rect = rl.Rectangle(self._rect.x, self._rect.y, style.TOGGLE_WIDTH, style.TOGGLE_BG_HEIGHT)
# Draw actual background
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
left_edge = bg_rect.x + KNOB_PADDING
right_edge = bg_rect.x + bg_rect.width - KNOB_PADDING
knob_travel_distance = right_edge - left_edge - 2 * KNOB_RADIUS
min_knob_x = left_edge + KNOB_RADIUS
knob_x = min_knob_x + knob_travel_distance * self._progress
knob_y = self._rect.y + style.TOGGLE_BG_HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), KNOB_RADIUS, knob_color)
clicked = self._clicked
self._clicked = False
return clicked
@@ -0,0 +1,296 @@
"""
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)