From 61b8f6f478e3f3eab07c384a4d45e52bcde00a09 Mon Sep 17 00:00:00 2001 From: Dean Lee Date: Tue, 10 Jun 2025 05:45:29 +0800 Subject: [PATCH] ui: Implement core device settings functionality with enhanced dynamic controls (#35507) * implement device settings functionality with power controls * format * Update selfdrive/ui/layouts/settings/device.py Co-authored-by: Shane Smiskol * Update selfdrive/ui/layouts/settings/device.py Co-authored-by: Shane Smiskol * add comment back * add comments back --------- Co-authored-by: Shane Smiskol --- selfdrive/ui/layouts/settings/device.py | 74 +++++++++--- selfdrive/ui/ui_state.py | 6 + system/ui/lib/list_view.py | 154 ++++++++++++++++++------ 3 files changed, 178 insertions(+), 56 deletions(-) diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 22eb6895c1..a67d8d5d9c 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -7,7 +7,8 @@ from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialo from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app, Widget, DialogResult -from openpilot.system.ui.lib.list_view import ListView, text_item, button_item +from openpilot.system.ui.lib.list_view import ListView, text_item, button_item, dual_button_item +from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog, alert_dialog @@ -28,55 +29,58 @@ class DeviceLayout(Widget): super().__init__() self._params = Params() + self._select_language_dialog: MultiOptionDialog | None = None + self._driver_camera: DriverCameraDialog | None = None + self._pair_device_dialog: PairingDialog | None = None + + items = self._initialize_items() + self._list_widget = ListView(items) + + def _initialize_items(self): dongle_id = self._params.get("DongleId", encoding="utf-8") or "N/A" serial = self._params.get("HardwareSerial") or "N/A" items = [ text_item("Dongle ID", dongle_id), text_item("Serial", serial), - button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], self._on_pair_device), - button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._on_driver_camera), - button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._on_reset_calibration), + button_item("Pair Device", "PAIR", DESCRIPTIONS['pair_device'], callback=self._pair_device), + button_item("Driver Camera", "PREVIEW", DESCRIPTIONS['driver_camera'], callback=self._show_driver_camera, enabled=ui_state.is_offroad), + button_item("Reset Calibration", "RESET", DESCRIPTIONS['reset_calibration'], callback=self._reset_calibration_prompt), + button_item("Regulatory", "VIEW", callback=self._on_regulatory, visible=TICI), button_item("Review Training Guide", "REVIEW", DESCRIPTIONS['review_guide'], self._on_review_training_guide), + button_item("Change Language", "CHANGE", callback=self._show_language_selection, enabled=ui_state.is_offroad), + dual_button_item("Reboot", "Power Off", left_callback=self._reboot_prompt, right_callback=self._power_off_prompt), ] - - if TICI: - items.append(button_item("Regulatory", "VIEW", callback=self._on_regulatory)) - - items.append(button_item("Change Language", "CHANGE", callback=self._on_change_language)) - - self._list_widget = ListView(items) - self._select_language_dialog: MultiOptionDialog | None = None - self._driver_camera: DriverCameraDialog | None = None + return items def _render(self, rect): self._list_widget.render(rect) - def _on_change_language(self): + def _show_language_selection(self): try: languages_file = os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json") with open(languages_file, encoding='utf-8') as f: languages = json.load(f) self._select_language_dialog = MultiOptionDialog("Select a language", languages) - gui_app.set_modal_overlay(self._select_language_dialog, callback=self._on_select_lang_dialog_closed) + gui_app.set_modal_overlay(self._select_language_dialog, callback=self._handle_language_selection) except FileNotFoundError: pass - def _on_select_lang_dialog_closed(self, result: int): + def _handle_language_selection(self, result: int): if result == 1 and self._select_language_dialog: selected_language = self._select_language_dialog.selection self._params.put("LanguageSetting", selected_language) self._select_language_dialog = None - def _on_driver_camera(self): + def _show_driver_camera(self): if not self._driver_camera: self._driver_camera = DriverCameraDialog() gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None)) - def _on_reset_calibration(self): + def _reset_calibration_prompt(self): if ui_state.engaged: gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Reset Calibration")) return @@ -97,6 +101,38 @@ class DeviceLayout(Widget): self._params.remove("LiveDelay") self._params.put_bool("OnroadCycleRequested", True) - def _on_pair_device(self): pass + def _reboot_prompt(self): + if ui_state.engaged: + gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Reboot")) + return + + gui_app.set_modal_overlay( + lambda: confirm_dialog("Are you sure you want to reboot?", "Reboot"), + callback=self._perform_reboot, + ) + + def _perform_reboot(self, result: int): + if not ui_state.engaged and result == DialogResult.CONFIRM: + self._params.put_bool_nonblocking("DoReboot", True) + + def _power_off_prompt(self): + if ui_state.engaged: + gui_app.set_modal_overlay(lambda: alert_dialog("Disengage to Power Off")) + return + + gui_app.set_modal_overlay( + lambda: confirm_dialog("Are you sure you want to power off?", "Power Off"), + callback=self._perform_power_off, + ) + + def _perform_power_off(self, result: int): + if not ui_state.engaged and result == DialogResult.CONFIRM: + self._params.put_bool_nonblocking("DoShutdown", True) + + def _pair_device(self): + if not self._pair_device_dialog: + self._pair_device_dialog = PairingDialog() + gui_app.set_modal_overlay(self._pair_device_dialog, callback=lambda result: setattr(self, '_pair_device_dialog', None)) + def _on_review_training_guide(self): pass def _on_regulatory(self): pass diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index 5ce67ac000..9e0afa6775 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -66,6 +66,12 @@ class UIState: def engaged(self) -> bool: return self.started and self.sm["selfdriveState"].enabled + def is_onroad(self) -> bool: + return self.started + + def is_offroad(self) -> bool: + return not self.started + def update(self) -> None: self.sm.update(0) self._update_state() diff --git a/system/ui/lib/list_view.py b/system/ui/lib/list_view.py index fa53cff54e..439f0e77bd 100644 --- a/system/ui/lib/list_view.py +++ b/system/ui/lib/list_view.py @@ -32,7 +32,9 @@ TEXT_PADDING = 20 def _resolve_value(value, default=""): - return value() if callable(value) else (value or default) + if callable(value): + return value() + return value if value is not None else default # Abstract base class for right-side items @@ -118,6 +120,34 @@ class TextAction(ItemAction): return int(text_width + TEXT_PADDING) +class DualButtonAction(ItemAction): + def __init__(self, left_text: str, right_text: str, left_callback: Callable = None, + right_callback: Callable = None, enabled: bool | Callable[[], bool] = True): + super().__init__(width=0, enabled=enabled) # Width 0 means use full width + self.left_text, self.right_text = left_text, right_text + self.left_callback, self.right_callback = left_callback, right_callback + + def _render(self, rect: rl.Rectangle) -> bool: + button_spacing = 30 + button_height = 120 + button_width = (rect.width - button_spacing) / 2 + button_y = rect.y + (rect.height - button_height) / 2 + + left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height) + right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height) + + left_clicked = gui_button(left_rect, self.left_text, button_style=ButtonStyle.LIST_ACTION) == 1 + right_clicked = gui_button(right_rect, self.right_text, button_style=ButtonStyle.DANGER) == 1 + + if left_clicked and self.left_callback: + self.left_callback() + return True + if right_clicked and self.right_callback: + self.right_callback() + return True + return False + + @dataclass class ListItem: title: str @@ -127,6 +157,7 @@ class ListItem: rect: "rl.Rectangle" = rl.Rectangle(0, 0, 0, 0) callback: Callable | None = None action_item: ItemAction | None = None + visible: bool | Callable[[], bool] = True # Cached properties for performance _prev_max_width: int = 0 @@ -134,27 +165,35 @@ class ListItem: _prev_description: str | None = None _description_height: float = 0 - def actiion(self) -> ItemAction | None: - return self.action_item + @property + def is_visible(self) -> bool: + return bool(_resolve_value(self.visible, True)) def get_description(self): return _resolve_value(self.description, None) def get_item_height(self, font: rl.Font, max_width: int) -> float: + if not self.is_visible: + return 0 + current_description = self.get_description() if self.description_visible and current_description: - if not self._wrapped_description or current_description != self._prev_description or max_width != self._prev_max_width: + if ( + not self._wrapped_description + or current_description != self._prev_description + or max_width != self._prev_max_width + ): self._prev_max_width = max_width self._prev_description = current_description wrapped_lines = wrap_text(font, current_description, ITEM_DESC_FONT_SIZE, max_width) self._wrapped_description = "\n".join(wrapped_lines) - self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 # Line height + padding + self._description_height = len(wrapped_lines) * ITEM_DESC_FONT_SIZE + 10 return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING return ITEM_BASE_HEIGHT def get_content_width(self, total_width: int) -> int: - if self.action_item: + if self.action_item and self.action_item.get_width() > 0: return total_width - self.action_item.get_width() - RIGHT_ITEM_PADDING return total_width @@ -163,6 +202,10 @@ class ListItem: return rl.Rectangle(0, 0, 0, 0) right_width = self.action_item.get_width() + if right_width == 0: # Full width action (like DualButtonAction) + return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, + item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT) + right_x = item_rect.x + item_rect.width - right_width right_y = item_rect.y return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) @@ -171,10 +214,10 @@ class ListItem: class ListView(Widget): def __init__(self, items: list[ListItem]): super().__init__() - self._items: list[ListItem] = items + self._items = items self.scroll_panel = GuiScrollPanel() self._font = gui_app.font(FontWeight.NORMAL) - self._hovered_item: int = -1 + self._hovered_item = -1 def _render(self, rect: rl.Rectangle): total_height = self._update_item_rects(rect) @@ -191,31 +234,64 @@ class ListView(Widget): rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) for i, item in enumerate(self._items): + if not item.is_visible: + continue + y = int(item.rect.y + scroll_offset.y) if y + item.rect.height <= rect.y or y >= rect.y + rect.height: continue self._render_item(item, y) - if i < len(self._items) - 1: + # Draw separator line + next_visible_item = self._get_next_visible_item(i) + if next_visible_item is not None: line_y = int(y + item.rect.height - 1) - rl.draw_line(int(item.rect.x) + LINE_PADDING, line_y, int(item.rect.x + item.rect.width) - LINE_PADDING * 2, line_y, LINE_COLOR) + rl.draw_line( + int(item.rect.x) + LINE_PADDING, + line_y, + int(item.rect.x + item.rect.width) - LINE_PADDING * 2, + line_y, + LINE_COLOR, + ) + rl.end_scissor_mode() + def _get_next_visible_item(self, current_index: int) -> int | None: + for i in range(current_index + 1, len(self._items)): + if self._items[i].is_visible: + return i + return None + + def _update_item_rects(self, container_rect: rl.Rectangle) -> float: + current_y = 0.0 + for item in self._items: + if not item.is_visible: + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, 0) + continue + + content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) + item_height = item.get_item_height(self._font, content_width) + item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) + current_y += item_height + return current_y # total height of all items + def _render_item(self, item: ListItem, y: int): content_x = item.rect.x + ITEM_PADDING text_x = content_x - # Draw icon if present - if item.icon: - icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE) - rl.draw_texture(icon_texture, int(content_x), int(y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE) - text_x += ICON_SIZE + ITEM_PADDING + # Only draw title and icon for items that have them + if item.title: + # Draw icon if present + if item.icon: + icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE) + rl.draw_texture(icon_texture, int(content_x), int(y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE) + text_x += ICON_SIZE + ITEM_PADDING - # Draw main text - text_size = measure_text_cached(self._font, item.title, ITEM_TEXT_FONT_SIZE) - item_y = y + (ITEM_BASE_HEIGHT - text_size.y) // 2 - rl.draw_text_ex(self._font, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) + # Draw main text + text_size = measure_text_cached(self._font, item.title, ITEM_TEXT_FONT_SIZE) + item_y = y + (ITEM_BASE_HEIGHT - text_size.y) // 2 + rl.draw_text_ex(self._font, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) # Draw description if visible current_description = item.get_description() @@ -223,7 +299,7 @@ class ListView(Widget): rl.draw_text_ex( self._font, item._wrapped_description, - (text_x, y + ITEM_DESC_V_OFFSET), + rl.Vector2(text_x, y + ITEM_DESC_V_OFFSET), ITEM_DESC_FONT_SIZE, 0, ITEM_DESC_TEXT_COLOR, @@ -238,15 +314,6 @@ class ListView(Widget): if item.callback: item.callback() - def _update_item_rects(self, container_rect: rl.Rectangle) -> float: - current_y = 0.0 - for item in self._items: - content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2)) - item_height = item.get_item_height(self._font, content_width) - item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height) - current_y += item_height - return current_y # total height of all items - def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2): mouse_pos = rl.get_mouse_position() @@ -257,6 +324,9 @@ class ListView(Widget): content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y for i, item in enumerate(self._items): + if not item.is_visible: + continue + if item.rect: # Check if mouse is within this item's bounds in content space if ( @@ -290,23 +360,33 @@ class ListView(Widget): # Factory functions -def simple_item(title: str, callback: Callable | None = None) -> ListItem: - return ListItem(title=title, callback=callback) +def simple_item(title: str, callback: Callable | None = None, visible: bool | Callable[[], bool] = True) -> ListItem: + return ListItem(title=title, callback=callback, visible=visible) def toggle_item(title: str, description: str | Callable[[], str] | None = None, initial_state: bool = False, - callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem: + callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, + visible: bool | Callable[[], bool] = True) -> ListItem: action = ToggleAction(initial_state=initial_state, enabled=enabled) - return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback) + return ListItem(title=title, description=description, action_item=action, icon=icon, callback=callback, visible=visible) def button_item(title: str, button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, - callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, + visible: bool | Callable[[], bool] = True) -> ListItem: action = ButtonAction(text=button_text, enabled=enabled) - return ListItem(title=title, description=description, action_item=action, callback=callback) + return ListItem(title=title, description=description, action_item=action, callback=callback, visible=visible) def text_item(title: str, value: str | Callable[[], str], description: str | Callable[[], str] | None = None, - callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, + visible: bool | Callable[[], bool] = True) -> ListItem: action = TextAction(text=value, color=rl.Color(170, 170, 170, 255), enabled=enabled) - return ListItem(title=title, description=description, action_item=action, callback=callback) + return ListItem(title=title, description=description, action_item=action, callback=callback, visible=visible) + + +def dual_button_item(left_text: str, right_text: str, left_callback: Callable = None, right_callback: Callable = None, + description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True, + visible: bool | Callable[[], bool] = True) -> ListItem: + action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled) + return ListItem(title="", description=description, action_item=action, visible=visible)