From 0cf294d85fbaffd94a83f08536cf7a1bb3e75c80 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 11 Sep 2026 20:13:00 -0700 Subject: [PATCH] ui: add descriptions for settings (#38867) --- .../ui/mici/layouts/settings/developer.py | 39 ++++---- .../ui/mici/layouts/settings/device.py | 17 ++-- .../mici/layouts/settings/network/__init__.py | 8 +- .../settings/network/network_layout.py | 14 ++- .../ui/mici/layouts/settings/software.py | 7 +- .../ui/mici/layouts/settings/toggles.py | 34 +++++-- openpilot/selfdrive/ui/mici/widgets/button.py | 92 +++++++++++-------- openpilot/selfdrive/ui/mici/widgets/dialog.py | 27 ++++++ openpilot/system/ui/widgets/__init__.py | 19 ++++ 9 files changed, 176 insertions(+), 81 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 2d7dc0899d..0b52e48bff 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -23,8 +23,6 @@ class AlphaLongConfirmPage(NavScroller): GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"), GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."), GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."), - GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."), - GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."), accept, ]) @@ -63,28 +61,31 @@ class DeveloperLayoutMici(NavScroller): txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" - self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) + self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh, + description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.") self._ssh_keys_btn.set_click_callback(ssh_keys_callback) # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) - self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) - self._joystick_toggle = BigToggle("joystick debug mode", - initial_state=ui_state.params.get_bool("JoystickDebugMode"), - toggle_callback=self._on_joystick_debug_mode) - self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", - initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), - toggle_callback=self._on_long_maneuver_mode) - self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", - initial_state=ui_state.params.get_bool("LateralManeuverMode"), - toggle_callback=self._on_lat_maneuver_mode) - self._alpha_long_toggle = BigToggle("alpha longitudinal", - initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), - toggle_callback=self._on_alpha_long_enabled) + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12), + description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB") + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12), + description="Access the device remotely using your SSH keys.", title="enable SSH") + self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.") + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode, + description="Run longitudinal maneuvers for testing gas and brake control.") + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode, + description="Run lateral maneuvers for testing steering control.") + self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled, + description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " + + "Braking (AEB).") self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", - toggle_callback=lambda checked: (gui_app.set_show_touches(checked), - gui_app.set_show_fps(checked))) + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)), + description="Show touch locations and the UI frame rate.") self._scroller.add_widgets([ self._adb_toggle, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 6038ff7d35..c038f5fddc 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -1,6 +1,7 @@ import os import pyray as rl from collections.abc import Callable +from typing import Union from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params @@ -77,15 +78,16 @@ def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.T class EngagedConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, - red: bool = False, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, red, icon_offset) + red: bool = False, icon_offset: tuple[int, int] = (0, 0), *, description: str = ""): + super().__init__(icon, red, icon_offset, description=description, title=title) self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) class EngagedConfirmationButton(BigButton): def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], - exit_on_confirm: bool = True, red: bool = False): - super().__init__(text, "", icon) + exit_on_confirm: bool = True, red: bool = False, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", icon, description=description, description_icon=description_icon) self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) @@ -177,7 +179,9 @@ class DeviceLayoutMici(NavScroller): params.put_bool("OnroadCycleRequested", True, block=True) reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), - reset_calibration_callback) + reset_calibration_callback, + description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " + + "continuously; resetting is rarely needed. Resetting clears learned calibration.") reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), reboot_callback, exit_on_confirm=False) @@ -189,7 +193,8 @@ class DeviceLayoutMici(NavScroller): regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) regulatory_btn.set_click_callback(self._on_regulatory) - cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.") cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index 89c106e236..bc297ed0e0 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -13,7 +13,7 @@ NetworkType = log.DeviceState.NetworkType class EsimNetworkButton(BigButton): - def __init__(self, cellular_manager: CellularManager): + def __init__(self, cellular_manager: CellularManager, *, description: str = ""): self._cellular_manager = cellular_manager self._cell_icons = { NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), @@ -22,7 +22,7 @@ class EsimNetworkButton(BigButton): NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), } - super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True) + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True, description=description) def _update_state(self): super()._update_state() @@ -54,7 +54,7 @@ class EsimNetworkButton(BigButton): class WifiNetworkButton(BigButton): - def __init__(self, wifi_manager: WifiManager): + def __init__(self, wifi_manager: WifiManager, *, description: str = ""): self._wifi_manager = wifi_manager self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36) self._draw_lock = False @@ -64,7 +64,7 @@ class WifiNetworkButton(BigButton): self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) + super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True, description=description) def _update_state(self): super()._update_state() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index d991ed5806..4203336469 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -29,7 +29,8 @@ class NetworkLayoutMici(NavScroller): self._network_metered_btn.set_enabled(False) self._wifi_manager.set_tethering_active(checked) - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, + description="Share the device’s internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -59,7 +60,9 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + "setting.") self._network_metered_btn.set_enabled(False) self._wifi_button = WifiNetworkButton(self._wifi_manager) @@ -76,14 +79,15 @@ class NetworkLayoutMici(NavScroller): # ******** Advanced settings ******** # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", description="Allow cellular data roaming.") # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn = BigButton("apn settings", "edit", + description="Set the access point name required by your cellular carrier. Leave blank for automatic configuration.") self._apn_btn.set_click_callback(self._edit_apn) # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered") + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", description="Prevent large uploads over the cellular connection.") # Main scroller ---------------------------------- self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 539ebaca11..7bf17713a3 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -242,7 +242,8 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): def __init__(self, check_update_btn: CheckUpdateButton): - super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "", + description="Select the software branch to download on the next update check.") self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) @@ -276,7 +277,9 @@ class SoftwareLayoutMici(NavScroller): uninstall_openpilot_btn = EngagedConfirmationButton("uninstall openpilot", "uninstall", gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), - uninstall_openpilot_callback, exit_on_confirm=False) + uninstall_openpilot_callback, exit_on_confirm=False, + description="Remove openpilot from this device.", + description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 4d5113448d..40a6188579 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -41,15 +41,33 @@ class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() - self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) - self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"), - toggle_callback=self._on_experimental_mode) + self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], + description="Standard is recommended.\n" + + "Aggressive follows closer, with firmer gas and braking.\n" + + "Relaxed leaves more space.\n" + + "Use the steering wheel distance button on supported cars.") + self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), + initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, + description="Let the driving model control gas and brakes.\n" + + "Includes stopping for red lights and stop signs.\n" + + "Set speed is a maximum, not a target.\n" + + "These are alpha features. Expect mistakes.\n" + + "The path colors show acceleration and braking.") is_metric_toggle = BigParamControl("use metric units", "IsMetric") - ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled") - always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") - record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback) - record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) - enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) + ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", + description="Warn when you drift across a detected lane line.\n" + + "Only above 31 mph (50 km/h), with no turn signal.") + always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when openpilot is not engaged.") + record_front = BigParamControl("record & upload cabin camera", "RecordFront", + description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.") + record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64), + toggle_callback=restart_needed_callback, + description="Record microphone audio while driving.\n" + + "Audio is included in dashcam videos in comma connect.") + enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback, + description="Enable to use openpilot driver assistance.\n" + + "Disable to use your car's stock driver assistance.") self._scroller.add_widgets([ self._personality_toggle, diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 59a3f95191..4f395ae9bc 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -29,9 +29,41 @@ class ScrollState(Enum): POST_SCROLL = 2 -class BigCircleButton(Widget): - def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): +class BaseButton(Widget): + def __init__(self, description: str, title: str, icon: Union[rl.Texture, None] = None): super().__init__() + self._shake_start: float | None = None + if description: + # Dialogs also use buttons; import lazily to avoid a circular import. + from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog + self.set_long_press_callback(lambda: gui_app.push_widget(SettingDescriptionDialog(title, description, icon))) + else: + self.set_long_press_callback(self.trigger_shake) + + def trigger_shake(self): + self._shake_start = rl.get_time() + + @property + def _shake_offset(self) -> float: + SHAKE_DURATION = 0.5 + SHAKE_AMPLITUDE = 24.0 + SHAKE_FREQUENCY = 32.0 + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start + if t > SHAKE_DURATION: + return 0.0 + decay = 1.0 - t / SHAKE_DURATION + return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) + + def set_position(self, x: float, y: float) -> None: + super().set_position(x + self._shake_offset, y) + +class BigCircleButton(BaseButton): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", + description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(description, title, description_icon or icon) self._red = red self._icon_offset = icon_offset @@ -73,8 +105,9 @@ class BigCircleButton(Widget): class BigCircleToggle(BigCircleButton): - def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, False, icon_offset=icon_offset) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(icon, False, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._toggle_callback = toggle_callback # State @@ -103,14 +136,16 @@ class BigCircleToggle(BigCircleButton): 0, 1.0, rl.WHITE) -class BigButton(Widget): +class BigButton(BaseButton): LABEL_HORIZONTAL_PADDING = 40 LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): - super().__init__() + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(description, text, description_icon or icon or None) self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value @@ -119,7 +154,6 @@ class BigButton(Widget): self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 - self._shake_start: float | None = None self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None @@ -187,28 +221,9 @@ class BigButton(Widget): def get_text(self): return self.text - def trigger_shake(self): - self._shake_start = rl.get_time() - def trigger_grow_animation(self, duration: float = 0.65): self._grow_animation_until = rl.get_time() + duration - @property - def _shake_offset(self) -> float: - SHAKE_DURATION = 0.5 - SHAKE_AMPLITUDE = 24.0 - SHAKE_FREQUENCY = 32.0 - if self._shake_start is None: - return 0.0 - t = rl.get_time() - self._shake_start - if t > SHAKE_DURATION: - return 0.0 - decay = 1.0 - t / SHAKE_DURATION - return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) - - def set_position(self, x: float, y: float) -> None: - super().set_position(x + self._shake_offset, y) - def _handle_background(self) -> tuple[rl.Texture, float, float, float]: if self._grow_animation_until is not None: if rl.get_time() >= self._grow_animation_until: @@ -272,8 +287,10 @@ class BigButton(Widget): class BigToggle(BigButton): - def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): - super().__init__(text, value, "") + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, value, "", description=description, description_icon=description_icon) self._checked = initial_state self._toggle_callback = toggle_callback @@ -308,8 +325,8 @@ class BigToggle(BigButton): class BigMultiToggle(BigToggle): def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): - super().__init__(text, "", toggle_callback=toggle_callback) + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) assert len(options) > 0 self._options = options self._select_callback = select_callback @@ -374,9 +391,9 @@ class GreyBigButton(BigButton): class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, options, toggle_callback, select_callback) + super().__init__(text, options, toggle_callback, select_callback, description=description, description_icon=description_icon) self._param = param self._params = Params() @@ -392,9 +409,10 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): - def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, "", toggle_callback=toggle_callback) + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) self.param = param self.params = Params() self.set_checked(self.params.get_bool(self.param, False)) @@ -410,9 +428,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, - icon_offset: tuple[int, int] = (0, 0)): + icon_offset: tuple[int, int] = (0, 0), *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): assert Params is not None - super().__init__(icon, toggle_callback, icon_offset=icon_offset) + super().__init__(icon, toggle_callback, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index c023369933..d5da6178f1 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -1,9 +1,11 @@ import abc import math +import re import pyray as rl from typing import Union from collections.abc import Callable from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -37,6 +39,31 @@ class BigDialog(BigDialogBase): )) +class SettingDescriptionDialog(NavScroller): + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): + super().__init__() + cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))] + # Explicit lines are authored cards; otherwise prefer sentence boundaries. + paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip()) + # Measure each card so longer text still fits with the actual font and padding. + for sentence in paragraphs: + card = GreyBigButton("", "") + words: list[str] = [] + for word in sentence.split(): + card.set_value(" ".join([*words, word])) + height = card._sub_label.get_content_height(card._subtitle_width_hint()) + if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING: + card.set_value(" ".join(words)) + cards.append(card) + card = GreyBigButton("", "") + words = [] + words.append(word) + if words: + card.set_value(" ".join(words)) + cards.append(card) + self._scroller.add_widgets(cards) + + class BigConfirmationDialog(BigDialogBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False): diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4e13920d60..e2e44c2543 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import time import pyray as rl from enum import IntEnum from typing import Protocol, TypeVar @@ -51,6 +52,8 @@ class Widget(abc.ABC): self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None + self._long_press_callback: Callable[[], None] | None = None + self._press_started: list[float | None] = [None] * MAX_TOUCH_SLOTS self._multi_touch = False self.__was_awake = True @@ -92,6 +95,9 @@ class Widget(abc.ABC): """Set a callback to be called when the widget is clicked.""" self._click_callback = click_callback + def set_long_press_callback(self, callback: Callable[[], None]) -> None: + self._long_press_callback = callback + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: """Set a callback to determine if the widget can be clicked.""" self._touch_valid_callback = touch_callback @@ -136,6 +142,7 @@ class Widget(abc.ABC): self._process_mouse_events() else: # TODO: ideally we emit release events when going disabled + self._press_started = [None] * MAX_TOUCH_SLOTS self.__is_pressed = [False] * MAX_TOUCH_SLOTS self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS @@ -160,6 +167,7 @@ class Widget(abc.ABC): # Allows touch to leave the rect and come back in focus if mouse did not release if mouse_event.left_pressed and touch_valid: if mouse_in_rect: + self._press_started[mouse_event.slot] = mouse_event.t self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True @@ -185,9 +193,20 @@ class Widget(abc.ABC): # Mouse/touch left our rect but may come back into focus later elif not mouse_in_rect: + self._press_started[mouse_event.slot] = None self.__is_pressed[mouse_event.slot] = False self._handle_mouse_event(mouse_event) + if self._long_press_callback is not None and touch_valid: + for slot, started in enumerate(self._press_started): + if started is not None and self.__is_pressed[slot] and time.monotonic() - started >= 0.45: + # Clear tracking before opening help so release cannot activate a toggle or action. + self._press_started[slot] = None + self.__is_pressed[slot] = False + self.__tracking_is_pressed[slot] = False + self._long_press_callback() + break + def _layout(self) -> None: """Optionally lay out child widgets separately. This is called before rendering."""