diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 8d6449cb4b..aab16ffbbe 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -107,7 +107,6 @@ jobs:
build_mac:
name: build macOS
- if: false # temp disable since gcc-arm-embedded install is getting stuck due to checksum mismatch
runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }}
steps:
- uses: actions/checkout@v4
diff --git a/selfdrive/assets/fonts/Audiowide-Regular.ttf b/selfdrive/assets/fonts/Audiowide-Regular.ttf
new file mode 100644
index 0000000000..1b6913947b
--- /dev/null
+++ b/selfdrive/assets/fonts/Audiowide-Regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:434a720871336d359378beff5ebff3f9fd654d958693d272c7c6f2e271c7e41c
+size 47676
diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py
index c99c8fe12c..f178c7c66a 100644
--- a/selfdrive/ui/layouts/home.py
+++ b/selfdrive/ui/layouts/home.py
@@ -177,7 +177,7 @@ class HomeLayout(Widget):
version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y,
version_text_width, self.header_rect.height)
- gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
+ gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, font_weight=FontWeight.AUDIOWIDE)
def _render_home_content(self):
self._render_left_column()
diff --git a/selfdrive/ui/sunnypilot/__init__.py b/selfdrive/ui/sunnypilot/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/selfdrive/ui/sunnypilot/layouts/__init__.py b/selfdrive/ui/sunnypilot/layouts/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/network.py b/selfdrive/ui/sunnypilot/layouts/settings/network.py
new file mode 100644
index 0000000000..14f573c628
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/network.py
@@ -0,0 +1,46 @@
+"""
+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 threading
+import time
+import pyray as rl
+
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.network import NetworkUI, PanelType
+
+
+class NetworkUISP(NetworkUI):
+ def __init__(self, wifi_manager):
+ super().__init__(wifi_manager)
+
+ self.scan_button = Button(tr("Scan"), self._scan_clicked, button_style=ButtonStyle.NORMAL, font_size=60, border_radius=30)
+ self.scan_button.set_rect(rl.Rectangle(0, 0, 400, 100))
+
+ self._scanning = False
+ self._wifi_manager.add_callbacks(networks_updated=self._on_networks_updated)
+
+ def _scan_clicked(self):
+ self._scanning = True
+ self.scan_button.set_text(tr("Scanning..."))
+ self.scan_button.set_enabled(False)
+
+ threading.Thread(target=self._wifi_manager._update_networks, daemon=True).start()
+ self._wifi_manager._request_scan()
+ self._wifi_manager._last_network_update = time.monotonic()
+
+ def _on_networks_updated(self, networks):
+ if self._scanning:
+ self._scanning = False
+ self.scan_button.set_text(tr("Scan"))
+ self.scan_button.set_enabled(True)
+
+ def _render(self, rect: rl.Rectangle):
+ super()._render(rect)
+
+ if self._current_panel == PanelType.WIFI:
+ self.scan_button.set_position(self._rect.x, self._rect.y + 20)
+ self.scan_button.render()
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
index bf174de90b..905338552f 100644
--- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py
+++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py
@@ -19,10 +19,10 @@ from openpilot.system.ui.lib.multilang import tr_noop
from openpilot.system.ui.sunnypilot.lib.styles import style
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.lib.text_measure import measure_text_cached
-from openpilot.system.ui.widgets.network import NetworkUI
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.network import NetworkUISP
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.sunnylink import SunnylinkLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.osm import OSMLayout
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.trips import TripsLayout
@@ -111,7 +111,7 @@ class SettingsLayoutSP(OP.SettingsLayout):
self._panels = {
OP.PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayoutSP(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_home.png"),
- OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager), icon="icons/network.png"),
+ OP.PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUISP(wifi_manager), icon="icons/network.png"),
OP.PanelType.SUNNYLINK: PanelInfo(tr_noop("sunnylink"), SunnylinkLayout(), icon="icons/shell.png"),
OP.PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_toggle.png"),
OP.PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout(), icon="../../sunnypilot/selfdrive/assets/offroad/icon_software.png"),
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle.py
deleted file mode 100644
index d04816a411..0000000000
--- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""
-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.common.params import Params
-from openpilot.system.ui.widgets.scroller_tici import Scroller
-from openpilot.system.ui.widgets import Widget
-
-
-class VehicleLayout(Widget):
- def __init__(self):
- super().__init__()
-
- self._params = Params()
- items = self._initialize_items()
- self._scroller = Scroller(items, line_separator=True, spacing=0)
-
- def _initialize_items(self):
- items = [
-
- ]
- return items
-
- def _render(self, rect):
- self._scroller.render(rect)
-
- def show_event(self):
- self._scroller.show_event()
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
new file mode 100644
index 0000000000..86d9e61695
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py
@@ -0,0 +1,67 @@
+"""
+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.multilang import tr
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.list_view import ButtonAction
+from openpilot.system.ui.widgets.scroller_tici import Scroller
+
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.factory import BrandSettingsFactory
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.platform_selector import PlatformSelector, LegendWidget
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP
+
+
+class VehicleLayout(Widget):
+ def __init__(self):
+ super().__init__()
+ self._brand_settings = None
+ self._brand_items = []
+ self._current_brand = None
+ self._platform_selector = PlatformSelector(self._update_brand_settings)
+
+ self._vehicle_item = ListItemSP(title=self._platform_selector.text, action_item=ButtonAction(text=tr("Select")),
+ callback=self._platform_selector._on_clicked)
+ self._vehicle_item.title_color = self._platform_selector.color
+ self._legend_widget = LegendWidget(self._platform_selector)
+
+ self.items = [self._vehicle_item, self._legend_widget]
+ self._scroller = Scroller(self.items, line_separator=True, spacing=0)
+
+ @staticmethod
+ def get_brand():
+ if bundle := ui_state.params.get("CarPlatformBundle"):
+ return bundle.get("brand", "")
+ elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK":
+ return ui_state.CP.brand
+ return ""
+
+ def _update_brand_settings(self):
+ self._vehicle_item._title = self._platform_selector.text
+ self._vehicle_item.title_color = self._platform_selector.color
+ vehicle_text = tr("Remove") if ui_state.params.get("CarPlatformBundle") else tr("Select")
+ self._vehicle_item.action_item.set_text(vehicle_text)
+
+ brand = self.get_brand()
+ if brand != self._current_brand:
+ self._current_brand = brand
+ self._brand_settings = BrandSettingsFactory.create_brand_settings(brand)
+ self._brand_items = self._brand_settings.items if self._brand_settings else []
+
+ self.items = [self._vehicle_item, self._legend_widget] + self._brand_items
+ self._scroller = Scroller(self.items, line_separator=True, spacing=0)
+
+ def _update_state(self):
+ self._update_brand_settings()
+ if self._brand_settings:
+ self._brand_settings.update_settings()
+ self._platform_selector.refresh()
+
+ def _render(self, rect):
+ self._scroller.render(rect)
+
+ def show_event(self):
+ self._scroller.show_event()
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py
new file mode 100644
index 0000000000..8d83fdf916
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/base.py
@@ -0,0 +1,16 @@
+"""
+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 abc
+
+
+class BrandSettings(abc.ABC):
+ def __init__(self):
+ self.items = []
+
+ @abc.abstractmethod
+ def update_settings(self) -> None:
+ """Update the settings based on the current vehicle brand."""
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py
new file mode 100644
index 0000000000..d1c9ea5d64
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/body.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class BodySettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py
new file mode 100644
index 0000000000..ad62dba56f
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/chrysler.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class ChryslerSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py
new file mode 100644
index 0000000000..678732296f
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/factory.py
@@ -0,0 +1,45 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.body import BodySettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.chrysler import ChryslerSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.ford import FordSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.gm import GMSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.honda import HondaSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.hyundai import HyundaiSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.mazda import MazdaSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.nissan import NissanSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.psa import PSASettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.rivian import RivianSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.subaru import SubaruSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.tesla import TeslaSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.toyota import ToyotaSettings
+from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.volkswagen import VolkswagenSettings
+
+
+class BrandSettingsFactory:
+ _BRAND_MAP: dict[str, type[BrandSettings]] = {
+ "body": BodySettings,
+ "chrysler": ChryslerSettings,
+ "ford": FordSettings,
+ "gm": GMSettings,
+ "honda": HondaSettings,
+ "hyundai": HyundaiSettings,
+ "mazda": MazdaSettings,
+ "nissan": NissanSettings,
+ "psa": PSASettings,
+ "rivian": RivianSettings,
+ "subaru": SubaruSettings,
+ "tesla": TeslaSettings,
+ "toyota": ToyotaSettings,
+ "volkswagen": VolkswagenSettings,
+ }
+
+ @staticmethod
+ def create_brand_settings(brand: str) -> BrandSettings | None:
+ cls = BrandSettingsFactory._BRAND_MAP.get(brand)
+ return cls() if cls is not None else None
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py
new file mode 100644
index 0000000000..8871087e03
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/ford.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class FordSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py
new file mode 100644
index 0000000000..edcd17cdb8
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/gm.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class GMSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py
new file mode 100644
index 0000000000..fec68795a6
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/honda.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class HondaSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py
new file mode 100644
index 0000000000..f6849eb201
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py
@@ -0,0 +1,59 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp
+from opendbc.car.hyundai.values import CAR, CANFD_UNSUPPORTED_LONGITUDINAL_CAR, UNSUPPORTED_LONGITUDINAL_CAR
+
+
+class HyundaiSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+ self.alpha_long_available = False
+
+ tuning_texts = [tr("Off"), tr("Dynamic"), tr("Predictive")]
+ self.longitudinal_tuning_item = multiple_button_item_sp(tr("Custom Longitudinal Tuning"), "", tuning_texts,
+ button_width=300, callback=self._on_tuning_selected,
+ param="HyundaiLongitudinalTuning", inline=False)
+ self.items = [self.longitudinal_tuning_item]
+
+ @staticmethod
+ def _on_tuning_selected(index):
+ ui_state.params.put("HyundaiLongitudinalTuning", index)
+
+ def update_settings(self):
+ self.alpha_long_available = False
+ bundle = ui_state.params.get("CarPlatformBundle")
+ if bundle:
+ platform = bundle.get("platform")
+ self.alpha_long_available = CAR[platform] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR)
+ elif ui_state.CP:
+ self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable
+
+ tuning_param = int(ui_state.params.get("HyundaiLongitudinalTuning") or "0")
+ long_enabled = ui_state.has_longitudinal_control
+
+ long_tuning_descs = [
+ tr("Your vehicle will use the Default longitudinal tuning."),
+ tr("Your vehicle will use the Dynamic longitudinal tuning."),
+ tr("Your vehicle will use the Predictive longitudinal tuning."),
+ ]
+ long_tuning_desc = long_tuning_descs[tuning_param] if tuning_param < len(long_tuning_descs) else long_tuning_descs[0]
+
+ longitudinal_tuning_disabled = not ui_state.is_offroad() or not long_enabled
+ if longitudinal_tuning_disabled:
+ if not ui_state.is_offroad():
+ long_tuning_desc = tr("This feature is unavailable while the car is onroad.")
+ elif not long_enabled:
+ long_tuning_desc = tr("This feature is unavailable because sunnypilot Longitudinal Control (Alpha) is not enabled.")
+
+ self.longitudinal_tuning_item.action_item.set_enabled(not longitudinal_tuning_disabled)
+ self.longitudinal_tuning_item.set_description(long_tuning_desc)
+ self.longitudinal_tuning_item.show_description(True)
+ self.longitudinal_tuning_item.action_item.set_selected_button(tuning_param)
+ self.longitudinal_tuning_item.set_visible(self.alpha_long_available)
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py
new file mode 100644
index 0000000000..d354f0f34b
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/mazda.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class MazdaSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py
new file mode 100644
index 0000000000..7b3446a1a7
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/nissan.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class NissanSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py
new file mode 100644
index 0000000000..6b767d332a
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/psa.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class PSASettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py
new file mode 100644
index 0000000000..876aa2d2ea
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/rivian.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class RivianSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py
new file mode 100644
index 0000000000..66e7ec1d5a
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/subaru.py
@@ -0,0 +1,54 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
+from opendbc.car.subaru.values import CAR, SubaruFlags
+
+
+class SubaruSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+ self.has_stop_and_go = False
+
+ self.stop_and_go_toggle = toggle_item_sp(tr("Stop and Go (Beta)"), "", param="SubaruStopAndGo", callback=self._on_toggle_changed)
+
+ self.stop_and_go_manual_parking_brake_toggle = toggle_item_sp(tr("Stop and Go for Manual Parking Brake (Beta)"), "",
+ param="SubaruStopAndGoManualParkingBrake", callback=self._on_toggle_changed)
+
+ self.items = [self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle]
+
+ def _on_toggle_changed(self, _):
+ self.update_settings()
+
+ def stop_and_go_disabled_msg(self):
+ if not self.has_stop_and_go:
+ return tr("This feature is currently not available on this platform.")
+ elif not ui_state.is_offroad():
+ return tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.")
+ return ""
+
+ def update_settings(self):
+ bundle = ui_state.params.get("CarPlatformBundle")
+ if bundle:
+ platform = bundle.get("platform")
+ config = CAR[platform].config
+ self.has_stop_and_go = not (config.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
+ elif ui_state.CP:
+ self.has_stop_and_go = not (ui_state.CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
+
+ disabled_msg = self.stop_and_go_disabled_msg()
+ descriptions = [
+ tr("Experimental feature to enable auto-resume during stop-and-go for certain supported Subaru platforms."),
+ tr("Experimental feature to enable stop and go for Subaru Global models with manual handbrake. " +
+ "Models with electric parking brake should keep this disabled. Thanks to martinl for this implementation!")
+ ]
+
+ for toggle, desc in zip([self.stop_and_go_toggle, self.stop_and_go_manual_parking_brake_toggle], descriptions, strict=True):
+ toggle.action_item.set_enabled(self.has_stop_and_go and ui_state.is_offroad())
+ toggle.set_description(f"{disabled_msg}
{desc}" if disabled_msg else desc)
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py
new file mode 100644
index 0000000000..46d536c651
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/tesla.py
@@ -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 openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
+
+COOP_STEERING_MIN_KMH = 23
+OEM_STEERING_MIN_KMH = 48
+KM_TO_MILE = 0.621371
+
+
+class TeslaSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+ self.coop_steering_toggle = toggle_item_sp(tr("Cooperative Steering (Beta)"), "", param="TeslaCoopSteering")
+ self.items = [self.coop_steering_toggle]
+
+ def update_settings(self):
+ is_metric = ui_state.is_metric
+ unit = "km/h" if is_metric else "mph"
+
+ display_value_coop = COOP_STEERING_MIN_KMH if is_metric else round(COOP_STEERING_MIN_KMH * KM_TO_MILE)
+ display_value_oem = OEM_STEERING_MIN_KMH if is_metric else round(OEM_STEERING_MIN_KMH * KM_TO_MILE)
+
+ coop_steering_disabled_msg = tr("Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle.")
+ coop_steering_warning = tr(f"Warning: May experience steering oscillations below {display_value_oem} {unit} during turns, " +
+ "recommend disabling this feature if you experience these.")
+ coop_steering_desc = (
+ f"{coop_steering_warning}
" +
+ f"{tr('Allows the driver to provide limited steering input while openpilot is engaged.')}
" +
+ f"{tr(f'Only works above {display_value_coop} {unit}.')}"
+ )
+
+ if not ui_state.is_offroad():
+ coop_steering_desc = f"{coop_steering_disabled_msg}
{coop_steering_desc}"
+
+ self.coop_steering_toggle.set_description(coop_steering_desc)
+ self.coop_steering_toggle.action_item.set_enabled(ui_state.is_offroad())
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
new file mode 100644
index 0000000000..e061a8a22a
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/toyota.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class ToyotaSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py
new file mode 100644
index 0000000000..a6d44c5e4d
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/volkswagen.py
@@ -0,0 +1,15 @@
+"""
+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.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base import BrandSettings
+
+
+class VolkswagenSettings(BrandSettings):
+ def __init__(self):
+ super().__init__()
+
+ def update_settings(self):
+ pass
diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py
new file mode 100644
index 0000000000..db90595274
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py
@@ -0,0 +1,138 @@
+"""
+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 json
+import os
+import pyray as rl
+from collections.abc import Callable
+from functools import partial
+
+from openpilot.common.basedir import BASEDIR
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.widgets import DialogResult, Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
+
+from openpilot.system.ui.sunnypilot.lib.styles import style
+from openpilot.system.ui.sunnypilot.widgets.tree_dialog import TreeOptionDialog, TreeNode, TreeFolder
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+CAR_LIST_JSON_OUT = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "car", "car_list.json")
+
+
+class LegendWidget(Widget):
+ def __init__(self, platform_selector):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 0, 350))
+ self._platform_selector = platform_selector
+ self._font = gui_app.font(FontWeight.NORMAL)
+ self._bold_font = gui_app.font(FontWeight.BOLD)
+
+ def _render(self, rect):
+ x = rect.x + 20
+ y = rect.y + 20
+ rl.draw_text_ex(self._font, tr("Select vehicle to force fingerprint manually."), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR)
+ y += 80
+ rl.draw_text_ex(self._font, tr("Colors represent vehicle fingerprint status:"), rl.Vector2(x, y), 40, 0, style.ITEM_DESC_TEXT_COLOR)
+ y += 80
+
+ items = [
+ (style.GREEN, tr("Fingerprinted automatically")),
+ (style.BLUE, tr("Manually selected fingerprint")),
+ (style.YELLOW, tr("Not fingerprinted or manually selected")),
+ ]
+ for color, text in items:
+ p_color = self._platform_selector.color
+ is_active = p_color.r == color.r and p_color.g == color.g and p_color.b == color.b and p_color.a == color.a
+ rl.draw_rectangle(int(x), int(y + 5), 30, 30, color)
+ font = self._bold_font if is_active else self._font
+ text_color = rl.WHITE if is_active else style.ITEM_DESC_TEXT_COLOR
+ rl.draw_text_ex(font, f"- {text}", rl.Vector2(x + 50, y - 7), 40, 0, text_color)
+ y += 50
+
+
+class PlatformSelector(Button):
+ def __init__(self, on_platform_change: Callable[[], None] | None = None):
+ super().__init__(tr("Vehicle"), self._on_clicked, button_style=ButtonStyle.NORMAL)
+ self.set_rect(rl.Rectangle(0, 0, 0, 120))
+
+ with open(CAR_LIST_JSON_OUT) as car_list_json:
+ self._platforms = json.load(car_list_json)
+
+ self._on_platform_change = on_platform_change
+ self.refresh()
+
+ @property
+ def text(self):
+ return self._label._text
+
+ def set_parent_rect(self, parent_rect):
+ super().set_parent_rect(parent_rect)
+ self._rect.width = parent_rect.width
+
+ def _on_clicked(self):
+ if ui_state.params.get("CarPlatformBundle"):
+ ui_state.params.remove("CarPlatformBundle")
+ self.refresh()
+ if self._on_platform_change:
+ self._on_platform_change()
+ else:
+ self._show_platform_dialog()
+
+ def _set_platform(self, platform_name):
+ if data := self._platforms.get(platform_name):
+ ui_state.params.put("CarPlatformBundle", {**data, "name": platform_name})
+ self.refresh()
+ if self._on_platform_change:
+ self._on_platform_change()
+
+ def _on_platform_selected(self, dialog, res):
+ if res == DialogResult.CONFIRM and dialog.selection_ref:
+ offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad else \
+ tr("This setting will take effect once the device enters offroad state.")
+
+ confirm_dialog = ConfirmDialog(offroad_msg, tr("Confirm"))
+
+ callback = partial(self._confirm_platform, dialog.selection_ref)
+ gui_app.set_modal_overlay(confirm_dialog, callback=callback)
+
+ def _confirm_platform(self, platform_name, res):
+ if res == DialogResult.CONFIRM:
+ self._set_platform(platform_name)
+
+ def _show_platform_dialog(self):
+ platforms = sorted(self._platforms.keys())
+ makes = sorted({self._platforms[p].get('make') for p in platforms})
+ folders = [TreeFolder(make, [TreeNode(p, {
+ 'display_name': p,
+ 'search_tags': f"{p} {self._platforms[p].get('make')} {' '.join(map(str, self._platforms[p].get('year', [])))} {self._platforms[p].get('model', p)}"
+ }) for p in platforms if self._platforms[p].get('make') == make]) for make in makes]
+ dialog = TreeOptionDialog(
+ tr("Select a vehicle"),
+ folders,
+ search_title=tr("Search your vehicle"),
+ search_subtitle=tr("Enter model year (e.g., 2021) and model (Toyota Corolla):"),
+ search_funcs=[lambda node: node.data.get('display_name', ''), lambda node: node.data.get('search_tags', '')]
+ )
+ callback = partial(self._on_platform_selected, dialog)
+ dialog.on_exit = callback
+ gui_app.set_modal_overlay(dialog, callback=callback)
+
+ def refresh(self):
+ self.color = style.YELLOW
+ self._platform = tr("Unrecognized Vehicle")
+ self.set_text(tr("No vehicle selected"))
+
+ if bundle := ui_state.params.get("CarPlatformBundle"):
+ self._platform = bundle.get("name", "")
+ self.set_text(self._platform)
+ self.color = style.BLUE
+ elif ui_state.CP and ui_state.CP.carFingerprint != "MOCK":
+ self._platform = ui_state.CP.carFingerprint
+ self.set_text(self._platform)
+ self.color = style.GREEN
+ self.set_enabled(True)
diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py
new file mode 100644
index 0000000000..1db5ad613e
--- /dev/null
+++ b/selfdrive/ui/sunnypilot/ui_state.py
@@ -0,0 +1,29 @@
+"""
+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 cereal import messaging, custom
+from openpilot.common.params import Params
+from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
+
+
+class UIStateSP:
+ def __init__(self):
+ self.params = Params()
+ self.sm_services_ext = [
+ "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
+ "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP"
+ ]
+
+ self.sunnylink_state = SunnylinkState()
+
+ def update(self) -> None:
+ self.sunnylink_state.start()
+
+ def update_params(self) -> None:
+ CP_SP_bytes = self.params.get("CarParamsSPPersistent")
+ if CP_SP_bytes is not None:
+ self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
+ self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled")
diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
index e64398d227..164358bfce 100755
--- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py
+++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
@@ -105,7 +105,7 @@ def setup_settings_software_branch_switcher(click, pm: PubMaster, scroll=None):
def setup_settings_firehose(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 850)
@@ -115,7 +115,7 @@ def setup_settings_developer(click, pm: PubMaster, scroll=None):
Params().put("CarParamsPersistent", CP.to_bytes())
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 950)
@@ -171,37 +171,37 @@ def setup_settings_steering(click, pm: PubMaster, scroll=None):
def setup_settings_cruise(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- click(278, 1017)
- scroll(-140, 278, 950)
+ scroll(-4, 278, 950)
+ click(278, 860)
def setup_settings_visuals(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 330)
def setup_settings_display(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 420)
def setup_settings_osm(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 520)
def setup_settings_trips(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 630)
def setup_settings_vehicle(click, pm: PubMaster, scroll=None):
setup_settings(click, pm)
- scroll(-1000, 278, 950)
+ scroll(-20, 278, 950)
click(278, 750)
@@ -342,9 +342,14 @@ class TestUI:
time.sleep(0.01)
pyautogui.mouseUp(self.ui.left + x, self.ui.top + y, *args, **kwargs)
- def scroll(self, clicks, x, y, *args, **kwargs):
- pyautogui.scroll(clicks, self.ui.left + x, self.ui.top + y, *args, **kwargs)
- time.sleep(UI_DELAY)
+ def scroll(self, clicks: int, x, y, *args, **kwargs):
+ if clicks == 0:
+ return
+ click = -1 if clicks < 0 else 1 # -1 = down, 1 = up
+ for _ in range(abs(clicks)):
+ pyautogui.scroll(click, self.ui.left + x, self.ui.top + y, *args, **kwargs) # scroll for individual clicks since we need to delay between clicks
+ time.sleep(0.01) # small delay between scroll clicks to work properly
+ time.sleep(2) # wait for scroll to fully settle
@with_processes(["ui"])
def test_ui(self, name, setup_case):
diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py
index ef0696a22c..c78fdccb59 100644
--- a/selfdrive/ui/ui_state.py
+++ b/selfdrive/ui/ui_state.py
@@ -12,6 +12,8 @@ from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.hardware import HARDWARE, PC
+from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP
+
BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
@@ -21,7 +23,7 @@ class UIStatus(Enum):
OVERRIDE = "override"
-class UIState:
+class UIState(UIStateSP):
_instance: 'UIState | None' = None
def __new__(cls):
@@ -31,6 +33,7 @@ class UIState:
return cls._instance
def _initialize(self):
+ UIStateSP.__init__(self)
self.params = Params()
self.sm = messaging.SubMaster(
[
@@ -55,7 +58,7 @@ class UIState:
"carControl",
"liveParameters",
"rawAudioData",
- ]
+ ] + self.sm_services_ext
)
self.prime_state = PrimeState()
@@ -111,6 +114,7 @@ class UIState:
if time.monotonic() - self._param_update_time > 5.0:
self.update_params()
device.update()
+ UIStateSP.update(self)
def _update_state(self) -> None:
# Handle panda states updates
@@ -180,6 +184,7 @@ class UIState:
self.has_longitudinal_control = self.params.get_bool("AlphaLongitudinalEnabled")
else:
self.has_longitudinal_control = self.CP.openpilotLongitudinalControl
+ UIStateSP.update_params(self)
self._param_update_time = time.monotonic()
diff --git a/sunnypilot/sunnylink/sunnylink_state.py b/sunnypilot/sunnylink/sunnylink_state.py
new file mode 100644
index 0000000000..4d0b397e03
--- /dev/null
+++ b/sunnypilot/sunnylink/sunnylink_state.py
@@ -0,0 +1,205 @@
+"""
+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 enum import IntEnum
+import threading
+import time
+import json
+
+from cereal import messaging
+from openpilot.common.params import Params
+from openpilot.common.swaglog import cloudlog
+from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID, SunnylinkApi
+
+
+class RoleType(IntEnum):
+ READONLY = 0
+ SPONSOR = 1
+ ADMIN = 2
+
+
+class SponsorTier(IntEnum):
+ FREE = 0
+ NOVICE = 1
+ SUPPORTER = 2
+ CONTRIBUTOR = 3
+ BENEFACTOR = 4
+ GUARDIAN = 5
+
+
+class User:
+ device_id: str
+ user_id: str
+ created_at: int
+ updated_at: int
+ token_hash: str
+
+ def __init__(self, json_data):
+ self.device_id = json_data.get("device_id")
+ self.user_id = json_data.get("user_id")
+ self.created_at = json_data.get("created_at")
+ self.updated_at = json_data.get("updated_at")
+ self.token_hash = json_data.get("token_hash")
+
+
+class Role:
+ role_type: str
+ role_tier: str
+
+ def __init__(self, json_data):
+ self.role_type = json_data.get("role_type")
+ self.role_tier = json_data.get("role_tier")
+
+
+def _parse_roles(roles: str) -> list[Role]:
+ lst_roles = []
+ try:
+ roles_list = json.loads(roles)
+ for r in roles_list:
+ try:
+ role = Role(r)
+ lst_roles.append(role)
+ except Exception as e:
+ cloudlog.exception(f"Failed to parse role {r}: {e}")
+ return lst_roles
+ except Exception as e:
+ cloudlog.exception(f"Error parsing roles: {e}")
+ return []
+
+
+def _parse_users(users: str) -> list[User]:
+ lst_users = []
+ try:
+ users_list = json.loads(users)
+ for u in users_list:
+ try:
+ user = User(u)
+ lst_users.append(user)
+ except Exception as e:
+ cloudlog.exception(f"Failed to parse user {u}: {e}")
+ return lst_users
+ except Exception as e:
+ cloudlog.exception(f"Error parsing users: {e}")
+ return []
+
+
+class SunnylinkState:
+ FETCH_INTERVAL = 5.0 # seconds between API calls
+ API_TIMEOUT = 10.0 # seconds for API requests
+ SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
+ NOT_PAIRED_USERNAMES = ["unregisteredsponsor", "temporarysponsor"]
+
+ def __init__(self):
+ self._params = Params()
+ self._lock = threading.Lock()
+ self._running = False
+ self._thread = None
+ self._sm = messaging.SubMaster(['deviceState'])
+
+ self._roles: list[Role] = []
+ self._users: list[User] = []
+ self.sponsor_tier: SponsorTier = SponsorTier.FREE
+ self.sunnylink_dongle_id = self._params.get("SunnylinkDongleId")
+ self._api = SunnylinkApi(self.sunnylink_dongle_id)
+
+ self._load_initial_state()
+
+ def _load_initial_state(self) -> None:
+ roles_cache = self._params.get("SunnylinkCache_Roles")
+ users_cache = self._params.get("SunnylinkCache_Users")
+ if roles_cache is not None:
+ self._roles = _parse_roles(roles_cache)
+ self.sponsor_tier = self._get_highest_tier()
+ if users_cache is not None:
+ self._users = _parse_users(users_cache)
+
+ def _get_highest_tier(self) -> SponsorTier:
+ role_tier = SponsorTier.FREE
+ for role in self._roles:
+ try:
+ if RoleType[role.role_type.upper()] == RoleType.SPONSOR:
+ role_tier = max(role_tier, SponsorTier[role.role_tier.upper()])
+ except Exception as e:
+ cloudlog.exception(f"Error parsing role {role}: {e} for dongle id {self.sunnylink_dongle_id}")
+ return role_tier
+
+ def _fetch_roles(self) -> None:
+ if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
+ return
+
+ try:
+ token = self._api.get_token()
+ response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/roles", method='GET', access_token=token)
+ if response.status_code == 200:
+ self._roles = _parse_roles(response.text)
+ self._params.put("SunnylinkCache_Roles", response.text)
+ sponsor_tier = self._get_highest_tier()
+ with self._lock:
+ if sponsor_tier != self.sponsor_tier:
+ self.sponsor_tier = sponsor_tier
+ cloudlog.info(f"Sunnylink sponsor tier updated to {sponsor_tier.name}")
+ except Exception as e:
+ cloudlog.exception(f"Failed to fetch sunnylink roles: {e} for dongle id {self.sunnylink_dongle_id}")
+
+ def _fetch_users(self) -> None:
+ if not self.sunnylink_dongle_id or self.sunnylink_dongle_id == UNREGISTERED_SUNNYLINK_DONGLE_ID:
+ return
+
+ try:
+ token = self._api.get_token()
+ response = self._api.api_get(f"device/{self.sunnylink_dongle_id}/users", method='GET', access_token=token)
+ if response.status_code == 200:
+ users = response.text
+ self._params.put("SunnylinkCache_Users", users)
+ with self._lock:
+ _parse_users(users)
+ except Exception as e:
+ cloudlog.exception(f"Failed to fetch sunnylink users: {e} for dongle id {self.sunnylink_dongle_id}")
+
+ def _worker_thread(self) -> None:
+ while self._running:
+ if self.is_connected():
+ self._fetch_roles()
+ self._fetch_users()
+
+ for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
+ if not self._running:
+ break
+ time.sleep(self.SLEEP_INTERVAL)
+
+ def start(self) -> None:
+ if self._thread and self._thread.is_alive():
+ return
+ self._running = True
+ self._thread = threading.Thread(target=self._worker_thread, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._running = False
+ if self._thread and self._thread.is_alive():
+ self._thread.join(timeout=1.0)
+
+ def get_sponsor_tier(self) -> SponsorTier:
+ with self._lock:
+ return self.sponsor_tier
+
+ def is_sponsor(self) -> bool:
+ with self._lock:
+ is_sponsor = any(role.role_type.upper() == RoleType.SPONSOR.name and role.role_tier.upper() != SponsorTier.FREE.name
+ for role in self._roles)
+ return is_sponsor
+
+ def is_paired(self) -> bool:
+ with self._lock:
+ is_paired = any(user.user_id not in self.NOT_PAIRED_USERNAMES for user in self._users)
+ return is_paired
+
+ def is_connected(self) -> bool:
+ network_type = self._sm["deviceState"].networkType
+ return bool(network_type != 0)
+
+ def __del__(self):
+ self.stop()
diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py
index e4850e9cbc..9c4dcee3f2 100644
--- a/system/ui/lib/application.py
+++ b/system/ui/lib/application.py
@@ -94,6 +94,7 @@ class FontWeight(StrEnum):
BOLD = "Inter-Bold.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "unifont.fnt"
+ AUDIOWIDE = "Audiowide-Regular.ttf"
# Small UI fonts
DISPLAY_REGULAR = "Inter-Regular.fnt"
diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py
index 0fb4a5c309..68e68d41a9 100644
--- a/system/ui/sunnypilot/lib/styles.py
+++ b/system/ui/sunnypilot/lib/styles.py
@@ -65,5 +65,15 @@ class DefaultStyleSP(Base):
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
+
+ # Vehicle Description Colors
+ GREEN = rl.Color(0, 241, 0, 255)
+ BLUE = rl.Color(0, 134, 233, 255)
+ YELLOW = rl.Color(255, 213, 0, 255)
+
style = DefaultStyleSP
diff --git a/system/ui/sunnypilot/widgets/helpers/__init__.py b/system/ui/sunnypilot/widgets/helpers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/system/ui/sunnypilot/widgets/helpers/fuzzy_search.py b/system/ui/sunnypilot/widgets/helpers/fuzzy_search.py
new file mode 100644
index 0000000000..8d0f6bd59b
--- /dev/null
+++ b/system/ui/sunnypilot/widgets/helpers/fuzzy_search.py
@@ -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
diff --git a/system/ui/sunnypilot/widgets/helpers/star_icon.py b/system/ui/sunnypilot/widgets/helpers/star_icon.py
new file mode 100644
index 0000000000..14666d49b6
--- /dev/null
+++ b/system/ui/sunnypilot/widgets/helpers/star_icon.py
@@ -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)
diff --git a/system/ui/sunnypilot/widgets/input_dialog.py b/system/ui/sunnypilot/widgets/input_dialog.py
index 88ab0b1a66..ed67302fcb 100644
--- a/system/ui/sunnypilot/widgets/input_dialog.py
+++ b/system/ui/sunnypilot/widgets/input_dialog.py
@@ -8,7 +8,6 @@ from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app
-from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.keyboard import Keyboard
@@ -27,14 +26,16 @@ class InputDialogSP:
def show(self):
self.keyboard.reset(min_text_size=self.keyboard._min_text_size)
- self.keyboard.set_title(tr(self.title), *(tr(self.sub_title),) if self.sub_title else ())
+ 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:
- if self.param:
- self._params.put(self.param, text)
+ if result == DialogResult.CONFIRM and self.param:
+ self._params.put(self.param, text)
if self.callback:
self.callback(result, text)
diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py
index 955adaa73e..f05de81397 100644
--- a/system/ui/sunnypilot/widgets/list_view.py
+++ b/system/ui/sunnypilot/widgets/list_view.py
@@ -80,16 +80,22 @@ class MultipleButtonActionSP(MultipleButtonAction):
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):
+ 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
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 = height + style.ITEM_BASE_HEIGHT/1.75
+ height += style.ITEM_BASE_HEIGHT / 1.75
+
return height
def show_description(self, show: bool):
@@ -100,13 +106,18 @@ class ListItemSP(ListItem):
return rl.Rectangle(0, 0, 0, 0)
if not self.inline:
- action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
+ has_description = bool(self.description) and self.description_visible
+
+ if has_description:
+ action_y = item_rect.y + self._text_size.y + style.ITEM_PADDING * 3
+ else:
+ action_y = item_rect.y + item_rect.height - style.BUTTON_HEIGHT - style.ITEM_PADDING * 1.5
+
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.rect.width
- if right_width == 0: # Full width action (like DualButtonAction)
- return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y,
- item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
+ 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)
action_width = self.action_item.rect.width
if isinstance(self.action_item, ToggleAction):
@@ -141,7 +152,7 @@ class ListItemSP(ListItem):
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, style.ITEM_TEXT_COLOR)
+ rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
# Render toggle and handle callback
if self.action_item.render(left_rect) and self.action_item.enabled:
@@ -153,7 +164,7 @@ class ListItemSP(ListItem):
# 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, style.ITEM_TEXT_COLOR)
+ 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:
@@ -170,7 +181,7 @@ class ListItemSP(ListItem):
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 * 1.75
+ 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)
diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py
new file mode 100644
index 0000000000..fd2a576d58
--- /dev/null
+++ b/system/ui/sunnypilot/widgets/tree_dialog.py
@@ -0,0 +1,240 @@
+"""
+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, gui_app
+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):
+ 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, [], "", 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._build_visible_items()
+
+ def _on_search_confirm(self, result, text):
+ if result == DialogResult.CONFIRM:
+ self.query = text
+ self._build_visible_items()
+ gui_app.set_modal_overlay(self, callback=self.on_exit)
+
+ 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 = []
+ 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:
+ 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]
+ self.scroller._items = self.visible_items
+ 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 + border
+ rl.draw_rectangle_rounded(input_rect, roundness, 10, rl.Color(0, 0, 0, 0))
+ rl.draw_rectangle_rounded_lines_ex(input_rect, roundness, 10, 3, rl.Color(150, 150, 150, 200))
+
+ # 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)
+
+ return self._result
+
+ def _handle_mouse_release(self, mouse_pos):
+ if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect):
+ self._on_search_clicked()
+ return True
+ return super()._handle_mouse_release(mouse_pos)