From 8f720a54f6c9c590fedfa9006745d354b7de117f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 22 Oct 2025 18:54:09 -0700 Subject: [PATCH] raylib: add branch switcher (#36359) * it's adversarial * try 2 * just do this * kinda works but doesn' tmatch * fine * qt is banned word * test * fix test * add elide support to Label * fixup * Revert "add elide support to Label" This reverts commit 28c3e0e7457345083d93f7b6a909a4103bd50d55. * Reapply "add elide support to Label" This reverts commit 92c2d6694146f164f30060d7621e19006e2fe2df. * todo * elide button value properly + debug/stash * clean up * clean up * yep looks good * clean up * eval visible once * no s * don't need * can do this * but this also works * clip to parent rect * fixes and multilang * clean up * set target branch * whops --- selfdrive/ui/layouts/settings/software.py | 46 +++++++++++++++---- .../ui/tests/test_ui/raylib_screenshots.py | 19 ++++++-- system/ui/widgets/button.py | 3 +- system/ui/widgets/label.py | 34 ++++++++++++-- system/ui/widgets/list_view.py | 19 +++++--- system/ui/widgets/option_dialog.py | 6 +-- 6 files changed, 101 insertions(+), 26 deletions(-) diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index 6f4629c75f..09b09ddec1 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -8,6 +8,7 @@ from openpilot.system.ui.lib.multilang import tr, trn from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from openpilot.system.ui.widgets.list_view import button_item, text_item, ListItem +from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.system.ui.widgets.scroller import Scroller # TODO: remove this. updater fails to respond on startup if time is not correct @@ -56,20 +57,20 @@ class SoftwareLayout(Widget): self._waiting_for_updater = False self._waiting_start_ts: float = 0.0 - items = self._init_items() - self._scroller = Scroller(items, line_separator=True, spacing=0) + # Branch switcher + self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch) + self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch")) + self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "") + self._branch_dialog: MultiOptionDialog | None = None - def _init_items(self): - items = [ + self._scroller = Scroller([ self._onroad_label, self._version_item, self._download_btn, self._install_btn, - # TODO: implement branch switching - # button_item("Target Branch", "SELECT", callback=self._on_select_branch), + self._branch_btn, button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall), - ] - return items + ], line_separator=True, spacing=0) def show_event(self): self._scroller.show_event() @@ -123,6 +124,10 @@ class SoftwareLayout(Widget): # Only enable if we're not waiting for updater to flip out of idle self._download_btn.action_item.set_enabled(not self._waiting_for_updater) + # Update target branch button value + current_branch = ui_state.params.get("UpdaterTargetBranch") or "" + self._branch_btn.action_item.set_value(current_branch) + # Update install button self._install_btn.set_visible(ui_state.is_offroad() and update_available) if update_available: @@ -163,4 +168,27 @@ class SoftwareLayout(Widget): self._install_btn.action_item.set_enabled(False) ui_state.params.put_bool("DoReboot", True) - def _on_select_branch(self): pass + def _on_select_branch(self): + # Get available branches and order + current_git_branch = ui_state.params.get("GitBranch") or "" + branches_str = ui_state.params.get("UpdaterAvailableBranches") or "" + branches = [b for b in branches_str.split(",") if b] + + for b in [current_git_branch, "devel-staging", "devel", "nightly", "nightly-dev", "master"]: + if b in branches: + branches.remove(b) + branches.insert(0, b) + + current_target = ui_state.params.get("UpdaterTargetBranch") or "" + self._branch_dialog = MultiOptionDialog("Select a branch", branches, current_target) + + def handle_selection(result): + # Confirmed selection + if result == DialogResult.CONFIRM and self._branch_dialog is not None and self._branch_dialog.selection: + selection = self._branch_dialog.selection + ui_state.params.put("UpdaterTargetBranch", selection) + self._branch_btn.action_item.set_value(selection) + os.system("pkill -SIGUSR1 -f system.updated.updated") + self._branch_dialog = None + + gui_app.set_modal_overlay(self._branch_dialog, callback=handle_selection) diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py index 2e96b3bd43..29fae5b61c 100755 --- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py +++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py @@ -27,6 +27,9 @@ TEST_OUTPUT_DIR = TEST_DIR / "raylib_report" SCREENSHOTS_DIR = TEST_OUTPUT_DIR / "screenshots" UI_DELAY = 0.2 +BRANCH_NAME = "this-is-a-really-super-mega-ultra-max-extreme-ultimate-long-branch-name" +VERSION = f"0.10.1 / {BRANCH_NAME} / 7864838 / Oct 03" + # Offroad alerts to test OFFROAD_ALERTS = ['Offroad_IsTakingSnapshot'] @@ -34,6 +37,7 @@ OFFROAD_ALERTS = ['Offroad_IsTakingSnapshot'] def put_update_params(params: Params): params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR)) params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR)) + params.put("UpdaterTargetBranch", BRANCH_NAME) def setup_homescreen(click, pm: PubMaster): @@ -89,6 +93,15 @@ def setup_settings_software_release_notes(click, pm: PubMaster): click(588, 110) # expand description for current version +def setup_settings_software_branch_switcher(click, pm: PubMaster): + setup_settings_software(click, pm) + params = Params() + params.put("UpdaterAvailableBranches", f"master,nightly,release,{BRANCH_NAME}") + params.put("GitBranch", BRANCH_NAME) # should be on top + params.put("UpdaterTargetBranch", "nightly") # should be selected + click(1984, 449) + + def setup_settings_firehose(click, pm: PubMaster): setup_settings(click, pm) click(278, 845) @@ -240,6 +253,7 @@ CASES = { "settings_software": setup_settings_software, "settings_software_download": setup_settings_software_download, "settings_software_release_notes": setup_settings_software_release_notes, + "settings_software_branch_switcher": setup_settings_software_branch_switcher, "settings_firehose": setup_settings_firehose, "settings_developer": setup_settings_developer, "keyboard": setup_keyboard, @@ -309,9 +323,8 @@ def create_screenshots(): params.put("DongleId", "123456789012345") # Set branch name - description = "0.10.1 / this-is-a-really-super-mega-long-branch-name / 7864838 / Oct 03" - params.put("UpdaterCurrentDescription", description) - params.put("UpdaterNewDescription", description) + params.put("UpdaterCurrentDescription", VERSION) + params.put("UpdaterNewDescription", VERSION) if name == "homescreen_paired": params.put("PrimeType", 0) # NONE diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index 84969d032e..bd6517ad49 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -88,6 +88,7 @@ class Button(Widget): text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER, text_padding: int = 20, icon=None, + elide_right: bool = False, multi_touch: bool = False, ): @@ -97,7 +98,7 @@ class Button(Widget): self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] self._label = Label(text, font_size, font_weight, text_alignment, text_padding=text_padding, - text_color=BUTTON_TEXT_COLOR[self._button_style], icon=icon) + text_color=BUTTON_TEXT_COLOR[self._button_style], icon=icon, elide_right=elide_right) self._click_callback = click_callback self._multi_touch = multi_touch diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 756495436b..10f6f1400d 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -37,17 +37,17 @@ def gui_label( # Elide text to fit within the rectangle if elide_right and text_size.x > rect.width: - ellipsis = "..." + _ellipsis = "..." left, right = 0, len(text) while left < right: mid = (left + right) // 2 - candidate = text[:mid] + ellipsis + candidate = text[:mid] + _ellipsis candidate_size = measure_text_cached(font, candidate, font_size) if candidate_size.x <= rect.width: left = mid + 1 else: right = mid - display_text = text[: left - 1] + ellipsis if left > 0 else ellipsis + display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis text_size = measure_text_cached(font, display_text, font_size) # Calculate horizontal position based on alignment @@ -106,6 +106,7 @@ class Label(Widget): text_padding: int = 0, text_color: rl.Color = DEFAULT_TEXT_COLOR, icon: Union[rl.Texture, None] = None, # noqa: UP007 + elide_right: bool = False, ): super().__init__() @@ -117,6 +118,7 @@ class Label(Widget): self._text_padding = text_padding self._text_color = text_color self._icon = icon + self._elide_right = elide_right self._text = text self.set_text(text) @@ -138,7 +140,31 @@ class Label(Widget): def _update_text(self, text): self._emojis = [] self._text_size = [] - self._text_wrapped = wrap_text(self._font, _resolve_value(text), self._font_size, round(self._rect.width - (self._text_padding * 2))) + text = _resolve_value(text) + + if self._elide_right: + display_text = text + + # Elide text to fit within the rectangle + text_size = measure_text_cached(self._font, text, self._font_size) + content_width = self._rect.width - self._text_padding * 2 + if text_size.x > content_width: + _ellipsis = "..." + left, right = 0, len(text) + while left < right: + mid = (left + right) // 2 + candidate = text[:mid] + _ellipsis + candidate_size = measure_text_cached(self._font, candidate, self._font_size) + if candidate_size.x <= content_width: + left = mid + 1 + else: + right = mid + display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis + + self._text_wrapped = [display_text] + else: + self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2))) + for t in self._text_wrapped: self._emojis.append(find_emoji(t)) self._text_size.append(measure_text_cached(self._font, t, self._font_size)) diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index a604c8e16d..e5f234ed39 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -100,6 +100,14 @@ class ButtonAction(ItemAction): ) self.set_enabled(enabled) + def get_width_hint(self) -> float: + value_text = self.value + if value_text: + text_width = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x + return text_width + BUTTON_WIDTH + TEXT_PADDING + else: + return BUTTON_WIDTH + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) self._button.set_touch_valid_callback(touch_callback) @@ -121,16 +129,15 @@ class ButtonAction(ItemAction): def _render(self, rect: rl.Rectangle) -> bool: self._button.set_text(self.text) self._button.set_enabled(_resolve_value(self.enabled)) - button_rect = rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) + button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT) self._button.render(button_rect) value_text = self.value if value_text: - spacing = 20 - text_size = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE) - text_x = button_rect.x - spacing - text_size.x - text_y = rect.y + (rect.height - text_size.y) / 2 - rl.draw_text_ex(self._font, value_text, rl.Vector2(text_x, text_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_VALUE_COLOR) + value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) + gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR, + font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) # TODO: just use the generic Widget click callbacks everywhere, no returning from render pressed = self._pressed diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py index 8c63ca3f9f..fc42b48081 100644 --- a/system/ui/widgets/option_dialog.py +++ b/system/ui/widgets/option_dialog.py @@ -28,11 +28,11 @@ class MultiOptionDialog(Widget): # Create scroller with option buttons self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt), text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL, - text_padding=50) for option in options] + text_padding=50, elide_right=True) for option in options] self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING) - self.cancel_button = Button(tr("Cancel"), click_callback=lambda: self._set_result(DialogResult.CANCEL)) - self.select_button = Button(tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY) + self.cancel_button = Button(lambda: tr("Cancel"), click_callback=lambda: self._set_result(DialogResult.CANCEL)) + self.select_button = Button(lambda: tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY) def _set_result(self, result: DialogResult): self._result = result