From 79fa8803b6499094d33abe788a9766ad2b003ead Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Mon, 1 Dec 2025 06:22:49 -0800 Subject: [PATCH 1/7] ui: add padding above tree dialog buttons (#1533) add padding --- system/ui/sunnypilot/widgets/tree_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index aa2904424..6458ebf00 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -160,7 +160,7 @@ class TreeOptionDialog(MultiOptionDialog): gui_label(rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 50, dialog_content_rect.width - 100, 70), self.title, 70, font_weight=FontWeight.BOLD) - options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 170, dialog_content_rect.width - 100, dialog_content_rect.height - 330) + options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 170, dialog_content_rect.width - 100, dialog_content_rect.height - 380) 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) From 859745ea8615817481ce8e5f25971f2f3c8f4a41 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:31:37 -0800 Subject: [PATCH 2/7] ui: tree dialog improvements (#1537) * ui: highlight on pressed, and less indent * inherit MultiOptionDialog main buttons * align top level folders to the edge properly * lint * handle folder presses too --------- Co-authored-by: Jason Wen --- system/ui/sunnypilot/widgets/tree_dialog.py | 57 ++++++++------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index 6458ebf00..5685a6c27 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -11,8 +11,8 @@ 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 -from openpilot.system.ui.widgets.label import gui_label, Label +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 @@ -34,7 +34,7 @@ class TreeFolder: class TreeItemWidget(Button): - def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False): + 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 @@ -46,14 +46,21 @@ class TreeItemWidget(Button): 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 if self.indent_level > 0 else 10 + indent = 60 * self.indent_level self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height) - color = style.BUTTON_PRIMARY_COLOR if self.selected and not (self.ref == "search_bar" or self.is_folder) else style.BUTTON_DISABLED_BG_COLOR + 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_rect = rl.Rectangle(self._rect.x + self.text_padding + 20, self._rect.y, self._rect.width - self.text_padding - 20 - 90, self._rect.height) + 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: @@ -86,8 +93,6 @@ class TreeOptionDialog(MultiOptionDialog): 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._build_visible_items() - self.cancel_rect = None - self.select_rect = None def _on_search_confirm(self, result, text): if result == DialogResult.CONFIRM: @@ -134,26 +139,14 @@ class TreeOptionDialog(MultiOptionDialog): 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)) + 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 _draw_button(self, button_rect, button_text, is_primary=False, is_enabled=True): - if is_primary and is_enabled: - button_color = style.BUTTON_PRIMARY_COLOR - elif not is_enabled: - button_color = style.BUTTON_NEUTRAL_GRAY - else: - button_color = style.BUTTON_DISABLED_BG_COLOR - roundness = 10 / (min(button_rect.width, button_rect.height) / 2) - rl.draw_rectangle_rounded(button_rect, roundness, 10, button_color) - label = Label(button_text, 60, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - text_color=rl.WHITE if is_enabled else rl.GRAY) - label.render(button_rect) - 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) @@ -169,18 +162,12 @@ class TreeOptionDialog(MultiOptionDialog): button_width = (dialog_content_rect.width - 150) / 2 button_y_position = dialog_content_rect.y + dialog_content_rect.height - 160 - self.cancel_rect = rl.Rectangle(dialog_content_rect.x + 50, button_y_position, button_width, 160) - self.select_rect = rl.Rectangle(dialog_content_rect.x + 100 + button_width, button_y_position, button_width, 160) - self._draw_button(self.cancel_rect, tr("Cancel")) - self._draw_button(self.select_rect, tr("Select"), True, self.selection != self.current) + 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.cancel_rect and rl.check_collision_point_rec(mouse_pos, self.cancel_rect): - self._set_result(DialogResult.CANCEL) - return True - if self.select_rect and rl.check_collision_point_rec(mouse_pos, self.select_rect) and self.selection != self.current: - self._set_result(DialogResult.CONFIRM) - return True - return super()._handle_mouse_release(mouse_pos) From 9ee965d2e0690640ac047888b3718b177f17063a Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 1 Dec 2025 21:35:28 -0500 Subject: [PATCH 3/7] ui: overridable title and subtitle for search query in `TreeOptionDialog` (#1538) * ui: overridable title and subtitle for `TreeOptionDialog` * lint --- system/ui/sunnypilot/widgets/input_dialog.py | 11 ++++++----- system/ui/sunnypilot/widgets/tree_dialog.py | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/system/ui/sunnypilot/widgets/input_dialog.py b/system/ui/sunnypilot/widgets/input_dialog.py index 88ab0b1a6..ed67302fc 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/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index 5685a6c27..4691f7f8f 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -77,7 +77,7 @@ class TreeItemWidget(Button): 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): + 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 @@ -92,6 +92,17 @@ class TreeOptionDialog(MultiOptionDialog): 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', '')] + + # Default title & overridable subtitle for InputDialogSP + self.search_title = search_title or tr("Enter search query") + self.search_subtitle = search_subtitle + self.search_dialog = InputDialogSP( + self.search_title, + self.search_subtitle, + current_text=self.query, + callback=self._on_search_confirm, + ) + self._build_visible_items() def _on_search_confirm(self, result, text): @@ -101,7 +112,7 @@ class TreeOptionDialog(MultiOptionDialog): gui_app.set_modal_overlay(self, callback=self.on_exit) def _on_search_clicked(self): - InputDialogSP(tr("Enter search query"), current_text=self.query, callback=self._on_search_confirm).show() + self.search_dialog.show() def _toggle_folder(self, folder): if folder.folder: From f312c011e8b3d9d0285be5fdeafcdcf161f84b93 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 1 Dec 2025 23:33:26 -0500 Subject: [PATCH 4/7] ui: magnifying glass icon and new search bar style in `TreeDialog` (#1541) * ui: magnifying glass icon and new search bar style in `TreeDialog` * cleanup --- system/ui/sunnypilot/widgets/tree_dialog.py | 63 +++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index 4691f7f8f..a8947a9b1 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -92,6 +92,8 @@ class TreeOptionDialog(MultiOptionDialog): 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") @@ -137,7 +139,7 @@ class TreeOptionDialog(MultiOptionDialog): self._build_visible_items(reset_scroll=False) def _build_visible_items(self, reset_scroll=True): - self.visible_items = [TreeItemWidget(self.query or self.search_prompt, "search_bar", False, 0, self._on_search_clicked)] + 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: @@ -161,10 +163,57 @@ class TreeOptionDialog(MultiOptionDialog): 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) - gui_label(rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 50, dialog_content_rect.width - 100, 70), - self.title, 70, font_weight=FontWeight.BOLD) - options_area_rect = rl.Rectangle(dialog_content_rect.x + 50, dialog_content_rect.y + 170, dialog_content_rect.width - 100, dialog_content_rect.height - 380) + # 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) @@ -182,3 +231,9 @@ class TreeOptionDialog(MultiOptionDialog): 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) From 7ba9876fa4586cf50c8110b0807f0bb3ca65e8ed Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Mon, 1 Dec 2025 23:44:01 -0500 Subject: [PATCH 5/7] ui: recreate search dialog with the latest query (#1542) --- system/ui/sunnypilot/widgets/tree_dialog.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index a8947a9b1..fd2a576d5 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -98,12 +98,7 @@ class TreeOptionDialog(MultiOptionDialog): # Default title & overridable subtitle for InputDialogSP self.search_title = search_title or tr("Enter search query") self.search_subtitle = search_subtitle - self.search_dialog = InputDialogSP( - self.search_title, - self.search_subtitle, - current_text=self.query, - callback=self._on_search_confirm, - ) + self.search_dialog = None self._build_visible_items() @@ -114,6 +109,12 @@ class TreeOptionDialog(MultiOptionDialog): 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): From 04504d47f3215de63b6fc7240087a9e0ac5468a6 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:02:33 -0800 Subject: [PATCH 6/7] ui: Platform Selector (#1508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * param to control stock vs sp ui * init styles * SP Toggles * Lint * optimizations * Panels. With Icons. And Scroller. * patience, grasshopper * more patience, grasshopper * sp raylib preview * fix callback * fix ui preview * add ui previews * dialog txt * compare vs what used to be done before InputDialog * introducing ui_state_sp for py * param to control stock vs sp ui * better * add ui_update callback * better padding * this * listitem -> listitemsp * Revert "add ui_update callback" This reverts commit 4da32cc0097434aab0aa6a3c35465eabb23c8958. * add show_description method * remove padding from line separator. like, WHY? 😩😩 * scroller -> scroller_tici * scroller -> scroller_tici * ui: `GuiApplicationExt` * add to readme * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * use gui_app.sunnypilot_ui() * lint * no fancy toggles :( * match them * mici scroller - no touchy * no * more * size adjustments * fix scroller. yay * ui vehicle panel: platform selector * platform selector * bruh * ui_state_sp * huh * rebase * rebae * vic * # Conflicts: # system/ui/sunnypilot/lib/styles.py # system/ui/sunnypilot/widgets/helpers/fuzzy_search.py * loathing loathing, unadulterated loathing, i loathe it all * more changes! * Update styles.py * add padding * use symlink on sp side * use make from json and show all actual makes * all done! * Revert "all done!" This reverts commit 595c45f057d97bd317e4a0518755d393aa289d0c. * reimpl onroad/offroad confirmation * use global offroad directly * ui: highlight on pressed, and less indent * inherit MultiOptionDialog main buttons * align top level folders to the edge properly * lint * lint * handle folder presses too * ui: overridable title and subtitle for `TreeOptionDialog` * override TreeOptionDialog title and subtitle * lint * more * ui: magnifying glass icon and new search bar style in `TreeDialog` * cleanup * ui: recreate search dialog with the latest query * make model year but display as platform * move into settings directory * move into dir --------- Co-authored-by: nayan Co-authored-by: Jason Wen --- selfdrive/ui/sunnypilot/__init__.py | 0 selfdrive/ui/sunnypilot/layouts/__init__.py | 0 .../sunnypilot/layouts/settings/__init__.py | 0 .../ui/sunnypilot/layouts/settings/vehicle.py | 30 ---- .../layouts/settings/vehicle/__init__.py | 43 ++++++ .../settings/vehicle/platform_selector.py | 141 ++++++++++++++++++ system/ui/sunnypilot/lib/styles.py | 5 + system/ui/sunnypilot/widgets/list_view.py | 7 +- 8 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 selfdrive/ui/sunnypilot/__init__.py create mode 100644 selfdrive/ui/sunnypilot/layouts/__init__.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/__init__.py delete mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.py create mode 100644 selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py diff --git a/selfdrive/ui/sunnypilot/__init__.py b/selfdrive/ui/sunnypilot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/sunnypilot/layouts/__init__.py b/selfdrive/ui/sunnypilot/layouts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/sunnypilot/layouts/settings/__init__.py b/selfdrive/ui/sunnypilot/layouts/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle.py deleted file mode 100644 index d04816a41..000000000 --- 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 000000000..23b861f8c --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/__init__.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.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.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._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) + + 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) + + def _update_state(self): + self._update_brand_settings() + + 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/platform_selector.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py new file mode 100644 index 000000000..60bc335ce --- /dev/null +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py @@ -0,0 +1,141 @@ +""" +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.brand = "" + 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.brand = bundle.get("brand", "") + 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.brand = ui_state.CP.brand + self.set_text(self._platform) + self.color = style.GREEN + self.set_enabled(True) diff --git a/system/ui/sunnypilot/lib/styles.py b/system/ui/sunnypilot/lib/styles.py index eb254166e..68e68d41a 100644 --- a/system/ui/sunnypilot/lib/styles.py +++ b/system/ui/sunnypilot/lib/styles.py @@ -70,5 +70,10 @@ class DefaultStyleSP(Base): 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/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index 955adaa73..0c87a06a1 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -80,8 +80,9 @@ 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 @@ -141,7 +142,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 +154,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: From eda189c56441d3439ca26a89dae4b086d20e06bf Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Tue, 2 Dec 2025 02:29:29 -0500 Subject: [PATCH 7/7] ui: refine height calculations and action placement in `ListViewSP` (#1543) * ui: refine height calculations and action placement in `ListViewSP` * relative --- system/ui/sunnypilot/widgets/list_view.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index 0c87a06a1..f05de8139 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -89,8 +89,13 @@ class ListItemSP(ListItem): 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): @@ -101,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): @@ -171,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)