sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
commit
de77c89cf6
3995 files changed
+1260688
No files matched your search
@@ -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 os
|
||||
|
||||
import pyray as rl
|
||||
|
||||
SHOW_MOUSE_COORDS = os.getenv("SHOW_MOUSE_COORDS") == "1"
|
||||
SUNNYPILOT_UI = os.getenv("SUNNYPILOT_UI", "1") == "1"
|
||||
|
||||
|
||||
class GuiApplicationExt:
|
||||
def __init__(self):
|
||||
self._show_mouse_coords = SHOW_MOUSE_COORDS
|
||||
|
||||
@staticmethod
|
||||
def sunnypilot_ui() -> bool:
|
||||
return SUNNYPILOT_UI
|
||||
|
||||
def _draw_mouse_coordinates(self, font):
|
||||
coords_text = f"X:{int(rl.get_mouse_x())}, Y:{int(rl.get_mouse_y())}"
|
||||
|
||||
green_color = rl.Color(0, 159, 47, 255) # Match the green color of FPS counter
|
||||
|
||||
# Calculate text width to position it at the right edge; estimate width based on text length
|
||||
# Each character is approximately 10-12 pixels wide at font size 20
|
||||
estimated_text_width = len(coords_text) * 11
|
||||
|
||||
# Position text at the top right corner, 10px from the top
|
||||
screen_width = self._scaled_width if self._scale != 1.0 else self._width
|
||||
text_pos = rl.Vector2(screen_width - estimated_text_width - 10, 6)
|
||||
|
||||
# Draw the text
|
||||
rl.draw_text_ex(font, coords_text, text_pos, 20, 0, green_color)
|
||||
|
||||
def set_show_mouse_coords(self, show: bool):
|
||||
self._show_mouse_coords = show
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
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
|
||||
|
||||
import pyray as rl
|
||||
|
||||
|
||||
@dataclass
|
||||
class Base:
|
||||
# Widget/Control Base Dimensions
|
||||
ITEM_BASE_HEIGHT = 170
|
||||
ITEM_PADDING = 20
|
||||
ITEM_TEXT_FONT_SIZE = 50
|
||||
ITEM_DESC_FONT_SIZE = 40
|
||||
ITEM_DESC_V_OFFSET = 150
|
||||
ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255)
|
||||
CLOSE_BTN_SIZE = 160
|
||||
|
||||
TEXT_PADDING = 20
|
||||
|
||||
# Toggle Control
|
||||
TOGGLE_HEIGHT = 120
|
||||
TOGGLE_WIDTH = int(TOGGLE_HEIGHT * 1.75)
|
||||
TOGGLE_BG_HEIGHT = TOGGLE_HEIGHT - 20
|
||||
|
||||
# Button Control
|
||||
BUTTON_ACTION_WIDTH = 300
|
||||
BUTTON_HEIGHT = 120
|
||||
|
||||
# Simple Button Control
|
||||
SIMPLE_BUTTON_WIDTH = 800
|
||||
SIMPLE_BUTTON_HEIGHT = 150
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultStyleSP(Base):
|
||||
# Base Colors
|
||||
BASE_BG_COLOR = rl.Color(57, 57, 57, 255) # Grey
|
||||
ON_BG_COLOR = rl.Color(28, 101, 186, 255) # Blue
|
||||
OFF_BG_COLOR = BASE_BG_COLOR
|
||||
ON_HOVER_BG_COLOR = rl.Color(17, 78, 150, 255) # Dark Blue
|
||||
OFF_HOVER_BG_COLOR = rl.Color(21, 21, 21, 255) # Dark gray
|
||||
DISABLED_ON_BG_COLOR = rl.Color(37, 70, 107, 255) # Dull Blue
|
||||
DISABLED_OFF_BG_COLOR = rl.Color(39, 39, 39, 255) # Grey
|
||||
ITEM_TEXT_COLOR = rl.WHITE
|
||||
ITEM_DISABLED_TEXT_COLOR = rl.Color(88, 88, 88, 255)
|
||||
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
|
||||
|
||||
# Toggle Control
|
||||
TOGGLE_ON_COLOR = ON_BG_COLOR
|
||||
TOGGLE_OFF_COLOR = OFF_BG_COLOR
|
||||
TOGGLE_KNOB_COLOR = rl.WHITE
|
||||
TOGGLE_DISABLED_ON_COLOR = DISABLED_ON_BG_COLOR
|
||||
TOGGLE_DISABLED_OFF_COLOR = DISABLED_OFF_BG_COLOR
|
||||
TOGGLE_DISABLED_KNOB_COLOR = rl.Color(88, 88, 88, 255) # Lighter Grey
|
||||
|
||||
# Multi Button Control
|
||||
MBC_TRANSPARENT = rl.Color(255, 255, 255, 0)
|
||||
MBC_BG_CHECKED_ENABLED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
MBC_DISABLED = rl.Color(0xFF, 0xFF, 0xFF, 0x33)
|
||||
|
||||
# Option Control
|
||||
OPTION_CONTROL_CONTAINER_BG = OFF_BG_COLOR
|
||||
OPTION_CONTROL_BTN_ENABLED = rl.Color(88, 88, 88, 255)
|
||||
OPTION_CONTROL_BTN_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
OPTION_CONTROL_BTN_DISABLED = DISABLED_OFF_BG_COLOR
|
||||
OPTION_CONTROL_TEXT_ENABLED = rl.WHITE
|
||||
OPTION_CONTROL_TEXT_PRESSED = rl.WHITE
|
||||
OPTION_CONTROL_TEXT_DISABLED = ITEM_DISABLED_TEXT_COLOR
|
||||
|
||||
# Tree Button Colors
|
||||
BUTTON_PRIMARY_COLOR = rl.Color(70, 91, 234, 255) # Royal Blue
|
||||
BUTTON_NEUTRAL_GRAY = rl.Color(51, 51, 51, 255)
|
||||
BUTTON_DISABLED_BG_COLOR = rl.Color(30, 30, 30, 255) # Very Dark Grey
|
||||
TREE_DIALOG_TRANSPARENT = rl.Color(0, 0, 0, 0)
|
||||
TREE_DIALOG_SEARCH_BUTTON_PRESSED = rl.Color(0x69, 0x68, 0x68, 0xFF)
|
||||
TREE_DIALOG_SEARCH_BUTTON_BORDER = rl.Color(150, 150, 150, 200)
|
||||
|
||||
# Vehicle Description Colors
|
||||
GREEN = rl.Color(0, 241, 0, 255)
|
||||
BLUE = rl.Color(0, 134, 233, 255)
|
||||
YELLOW = rl.Color(255, 213, 0, 255)
|
||||
|
||||
# Button Colors
|
||||
BUTTON_ENABLED_OFF = rl.Color(0x39, 0x39, 0x39, 0xFF)
|
||||
BUTTON_OFF_PRESSED = rl.Color(0x4A, 0x4A, 0x4A, 0xFF)
|
||||
BUTTON_DISABLED = rl.Color(0x12, 0x12, 0x12, 0xFF)
|
||||
BUTTON_TEXT_DISABLED = rl.Color(0x5C, 0x5C, 0x5C, 0xFF)
|
||||
|
||||
|
||||
style = DefaultStyleSP
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
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.sunnypilot.widgets.list_view import ButtonActionSP
|
||||
|
||||
|
||||
class NoElideButtonAction(ButtonActionSP):
|
||||
def get_width_hint(self):
|
||||
return super().get_width_hint() + 1
|
||||
|
||||
|
||||
class AlertFadeAnimator:
|
||||
def __init__(self, target_fps: int, duration_on: float = 0.75, rc: float = 0.05):
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
self._filter = FirstOrderFilter(1.0, rc, 1 / target_fps)
|
||||
self._frame = 0
|
||||
self._target_fps = target_fps
|
||||
self._duration_on = duration_on
|
||||
|
||||
def update(self, active: bool):
|
||||
if active:
|
||||
self._frame += 1
|
||||
if (self._frame % self._target_fps) < (self._target_fps * self._duration_on):
|
||||
self._filter.x = 1.0
|
||||
else:
|
||||
self._filter.update(0.0)
|
||||
else:
|
||||
self._frame = 0
|
||||
self._filter.update(1.0)
|
||||
|
||||
@property
|
||||
def alpha(self) -> float:
|
||||
return self._filter.x
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user