From 2ee5d1f8fa7439111d899b95bfe2f78be410556f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 19 Aug 2026 16:54:04 -0700 Subject: [PATCH 1/7] Revert "ui: fix text and icon overlap on button (#38628)" This reverts commit d9c4120f891430da60a353e91600483ef0292de1. --- openpilot/selfdrive/ui/mici/widgets/button.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index cad40d7d011705..0ecda6a0c56c5b 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -150,8 +150,8 @@ def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None) def _width_hint(self) -> int: - # A value moves the title to the top, where it shares space with the icon. - icon_size = self._txt_icon.width if self._txt_icon and self.value else 0 + # Single line if scrolling, so hide behind icon if exists + icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0 return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size) def _get_label_font_size(self): From 6f1a90f28d94288caba7183f5eb3ef08a71c0bae Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 19 Aug 2026 17:18:28 -0700 Subject: [PATCH 2/7] simple --- openpilot/selfdrive/ui/mici/widgets/button.py | 25 ++-- test_buttons.py | 112 ++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) create mode 100755 test_buttons.py diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 0ecda6a0c56c5b..d3dd5df6f2533f 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -149,11 +149,16 @@ def _load_images(self): def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None) - def _width_hint(self) -> int: + def _title_width_hint(self) -> int: # Single line if scrolling, so hide behind icon if exists - icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0 + icon_size = self._txt_icon.width if self._txt_icon and self.value else 0 return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size) + def _subtitle_width_hint(self) -> int: + # Single line if scrolling, so hide behind icon if exists + icon_size = self._txt_icon.width if self._txt_icon else 0 + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) + def _get_label_font_size(self): if len(self.text) <= 18: return 48 @@ -228,14 +233,16 @@ def _draw_content(self, btn_y: float): label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) self._label.set_color(label_color) - label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(), + label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._title_width_hint(), self._rect.height - self.LABEL_VERTICAL_PADDING * 2) self._label.render(label_rect) if self.value: - label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint()) + # TODO: can we inspect label rect?? + label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._title_width_hint()) + print(label_y, btn_y, self._label.rect.x, self._label.rect.y, self._label.rect.width, self._label.rect.height) sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y - sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height) + sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height) self._sub_label.render(sub_label_rect) # ICON ------------------------------------------------------------------- @@ -312,8 +319,8 @@ def __init__(self, text: str, options: list[str], toggle_callback: Callable | No self.set_value(self._options[0]) - def _width_hint(self) -> int: - return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width) + # def _width_hint(self) -> int: + # return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) @@ -363,8 +370,8 @@ def __init__(self, *args, **kwargs): def LABEL_VERTICAL_PADDING(self): return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18 - def _width_hint(self) -> int: - return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) + # def _width_hint(self) -> int: + # return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) def _get_label_font_size(self): return 36 diff --git a/test_buttons.py b/test_buttons.py new file mode 100755 index 00000000000000..547fac7b951e89 --- /dev/null +++ b/test_buttons.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +import os +import time + +from openpilot.cereal import messaging +from openpilot.common.hardware import COMMA_HARDWARE +from openpilot.common.realtime import Priority, config_realtime_process, set_core_affinity +from openpilot.system.ui.lib.application import gui_app +# from openpilot.selfdrive.ui.layouts.main import MainLayout +# from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle, GreyBigButton +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.selfdrive.ui.ui_state import ui_state + +BIG_UI = gui_app.big_ui() + + +class Font64BigButton(BigButton): + """SettingsBigButton (settings.py) and PairBigButton (device.py) both bump the title to 64""" + + def _get_label_font_size(self): + return 64 + + +class MainLayout(Scroller): + def __init__(self): + super().__init__() + + # icons at the sizes the UI actually loads them at + txt_settings = gui_app.texture("icons_mici/settings.png", 64, 64) + txt_info = gui_app.texture("icons_mici/settings/device/info.png", 64, 64) + txt_cameras = gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64) + txt_comma = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + txt_reboot = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70) + txt_update = gui_app.texture("icons_mici/settings/device/update.png", 64, 75) + txt_up_to_date = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) + txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) + txt_wifi = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) + txt_warning = gui_app.texture("icons_mici/setup/warning.png", 64, 64) + txt_warning_58 = gui_app.texture("icons_mici/setup/warning.png", 64, 58) + txt_green_dm = gui_app.texture("icons_mici/setup/green_dm.png", 64, 64) + txt_factory_reset = gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64) + + self._scroller.add_widgets([ + # BigButton: title, icon, no value (settings.py SettingsBigButton) + Font64BigButton("toggles", "", txt_settings), + # BigButton: single line title, icon, no value (device.py regulatory info, network_layout.py tethering password) + BigButton("regulatory info", "", txt_info), + # BigButton: multi-line title, icon, no value (device.py) + BigButton("driver\ncamera preview", "", txt_cameras), + # BigButton: title, value, icon (device.py PairBigButton) + Font64BigButton("pair", "connect.comma.ai", txt_comma), + # BigButton: title, value, icon (developer.py SSH keys) + BigButton("SSH keys", "Not set", txt_ssh), + # BigButton: title, value, icon (software.py InstallUpdateButton) + BigButton("install update", "0.10.1 (release-chestnut)", txt_reboot), + # BigButton: no title, value, icon (software.py CheckUpdateButton, title cleared when a value is set) + BigButton("", "updater failed\nto respond", txt_update), + # BigButton: title, value, no icon (network_layout.py apn settings) + BigButton("apn settings", "edit"), + # BigButton: no title, value, no icon (wifi_ui.py ScanningButton) + BigButton("", "searching for networks"), + # BigButton: title, value, icon, scrolling title (network/__init__.py WifiNetworkButton) + BigButton("a-really-long-network-name", "192.168.100.100", txt_wifi, scroll=True), + # BigButton: title, icon, no value, scrolling title (software.py branch picker, current target) + BigButton("release3-staging", "", txt_up_to_date, scroll=True), + # BigButton: title only, no icon, scrolling title (software.py branch picker, other branches) + BigButton("master-ci", "", None, scroll=True), + + # BigToggle: title only, off (developer.py) + BigToggle("joystick debug mode"), + # BigToggle: title only, on, wraps to two lines (developer.py) + BigToggle("longitudinal maneuver mode", initial_state=True), + # BigMultiToggle: title + the current option as the value (network_layout.py) + BigMultiToggle("network usage", ["default", "metered", "unmetered"]), + # BigMultiToggle: longer title (toggles.py BigMultiParamToggle) + BigMultiToggle("driving personality", ["aggressive", "standard", "relaxed"]), + + # GreyBigButton: multi-line title, single line value, icon (toggles.py, onboarding.py, mici_setup.py headers) + GreyBigButton("enabling\nexperimental mode", "scroll to continue", txt_warning), + # GreyBigButton: single line title, single line value, icon (developer.py, onboarding.py) + GreyBigButton("cabin camera data", "do you want to share video data for training?", txt_green_dm), + # GreyBigButton: single line title, multi-line value, icon (mici_reset.py, mici_updater.py, onboarding.py) + GreyBigButton("factory reset", "resetting erases\nall user content & data", txt_factory_reset), + # GreyBigButton: same shape with the shorter icon (mici_setup.py FailedPage, description defaulted) + GreyBigButton("download failed", "swipe down to go\nback and try again", txt_warning_58), + # GreyBigButton: title only (toggles.py section headers) + GreyBigButton("End-to-End Longitudinal Control"), + # GreyBigButton: no title, wrapping value, no icon (toggles.py body copy, BigDialog) + GreyBigButton("", "openpilot will drive as it thinks a human would, including stopping for red lights and stop signs."), + # GreyBigButton: no title, explicit multi-line value, no icon (mici_reset.py, mici_setup.py reason card) + GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"), + ]) + + + +def main(): + cores = {5, } + # above plannerd and radard + config_realtime_process(0, Priority.CTRL_HIGH) + + gui_app.init_window("UI") + ml = MainLayout() + gui_app.push_widget(ml) + + + for should_render, frame_time, cpu_time in gui_app.render(): + ui_state.update() + + +if __name__ == "__main__": + main() From bb435edf8ff4fdd9f530fa79f536c9797a0050e2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 19 Aug 2026 17:30:51 -0700 Subject: [PATCH 3/7] can do this --- openpilot/selfdrive/ui/mici/widgets/button.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index d3dd5df6f2533f..5db35a5c66c2cc 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -238,8 +238,7 @@ def _draw_content(self, btn_y: float): self._label.render(label_rect) if self.value: - # TODO: can we inspect label rect?? - label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._title_width_hint()) + label_y = label_rect.y + self._label.get_content_height(int(label_rect.width)) print(label_y, btn_y, self._label.rect.x, self._label.rect.y, self._label.rect.width, self._label.rect.height) sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height) From b271a350182ad87f9942d7363383ee8ec72d0e36 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 19 Aug 2026 17:31:22 -0700 Subject: [PATCH 4/7] fix eliding --- openpilot/system/ui/widgets/label.py | 112 +++++++++++---------------- 1 file changed, 45 insertions(+), 67 deletions(-) diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index a3e827321cfbf6..0ddc891ed85021 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -290,6 +290,9 @@ def __init__(self, self._cached_line_sizes: list[rl.Vector2] = [] self._cached_total_height: float | None = None self._cached_width: int = -1 + # visible lines depend on the rect height too, so they get their own guard + self._cached_visible: tuple[list[str], list[rl.Vector2]] = ([], []) + self._cached_visible_height: float | None = None # If max_width is set, initialize rect size for Scroller support if max_width is not None: @@ -395,6 +398,7 @@ def _update_text_cache(self, available_width: int): self._cached_text = text self._cached_width = available_width + self._cached_visible_height = None # Determine wrapping width content_width = available_width - (self._text_padding * 2) @@ -430,20 +434,30 @@ def _update_text_cache(self, available_width: int): self._cached_line_sizes.append(size) - # Calculate total height - # Each line contributes its measured height * line_height (matching Label's behavior) - # This includes spacing to the next line - if self._cached_line_sizes: - # Match the rendering logic: first line doesn't get line_height scaling - total_height = 0.0 - for idx, size in enumerate(self._cached_line_sizes): - if idx == 0: - total_height += size.y - else: - total_height += size.y * self._line_height - self._cached_total_height = total_height - else: - self._cached_total_height = 0.0 + self._cached_total_height = self._block_height(len(self._cached_line_sizes)) + + @property + def _line_px(self) -> float: + """Every line measures the same height, so line metrics are arithmetic rather than per line sums.""" + return self._cached_line_sizes[0].y if self._cached_line_sizes else self._font_size * FONT_SCALE + + def _block_height(self, lines: int) -> float: + """Height of n stacked lines. The first isn't scaled by line_height since nothing sits above it.""" + return self._line_px * (1 + (lines - 1) * self._line_height) if lines > 0 else 0.0 + + def _visible_lines(self, content_width: int) -> tuple[list[str], list[rl.Vector2]]: + """Lines that fit the rect height, with everything dropped reflowed onto the last one.""" + if self._cached_visible_height != self._rect.height: + self._cached_visible_height = self._rect.height + # always keep one line, it gets clipped by the scissor if it doesn't fit + max_lines = max(int(self._rect.height / (self._line_px * self._line_height)), 1) + lines, sizes = self._cached_wrapped_lines[:max_lines], self._cached_line_sizes[:max_lines] + if self._elide and len(self._cached_wrapped_lines) > max_lines: + # force elide so "..." shows even when the reflowed text happens to fit, to indicate more content + lines[-1] = self._elide_line(self._text_from_line(max_lines - 1), content_width, force=True) + sizes[-1] = measure_text_cached(self._font, lines[-1], self._font_size, self._spacing_pixels) + self._cached_visible = lines, sizes + return self._cached_visible def _elide_line(self, line: str, max_width: int, force: bool = False) -> str: """Elide a single line if it exceeds max_width. If force is True, always elide even if it fits.""" @@ -474,6 +488,21 @@ def _elide_line(self, line: str, max_width: int, force: bool = False) -> str: right = mid return line[:left - 1] + ellipsis if left > 0 else ellipsis + def _text_from_line(self, line_idx: int) -> str: + """The original text starting at the given wrapped line, collapsed onto one line. + + Walks the source text by non-whitespace character count instead of joining the wrapped lines, so + words that wrap_text split mid-word don't get a space inserted into them. + """ + consumed = sum(len(line) - line.count(" ") for line in self._cached_wrapped_lines[:line_idx]) + text = self.text + i = seen = 0 + while i < len(text) and seen < consumed: + if not text[i].isspace(): + seen += 1 + i += 1 + return " ".join(text[i:].split()) + def get_content_height(self, max_width: int) -> float: """ Returns the height needed for text at given max_width. @@ -503,62 +532,11 @@ def _render(self, _): if not self._cached_wrapped_lines: return - # Calculate which lines fit in the available height - visible_lines: list[str] = [] - visible_sizes: list[rl.Vector2] = [] - - current_height = 0.0 - broke_early = False - for line, size in zip( - self._cached_wrapped_lines, - self._cached_line_sizes, - strict=True): - - # Calculate height needed for this line - # Each line contributes its height * line_height (matching Label's behavior) - line_height_needed = size.y * self._line_height - - # Check if this line fits - if current_height + line_height_needed > self._rect.height: - # This line doesn't fit - if len(visible_lines) == 0: - # First line doesn't fit by height - still show it (will be clipped by scissor if needed) - # Continue to add this line below - pass - else: - # We have visible lines and this one doesn't fit - mark that we broke early - broke_early = True - break - - visible_lines.append(line) - visible_sizes.append(size) - - current_height += line_height_needed - - # If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line - if broke_early and len(visible_lines) > 0 and self._elide: - content_width = int(available_width - (self._text_padding * 2)) - if content_width <= 0: - content_width = 1 - - last_line_idx = len(visible_lines) - 1 - last_line = visible_lines[last_line_idx] - # Force elide the last line to show "..." even if it fits in width (to indicate more content) - elided = self._elide_line(last_line, content_width, force=True) - visible_lines[last_line_idx] = elided - visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels) - + visible_lines, visible_sizes = self._visible_lines(max(int(available_width - self._text_padding * 2), 1)) if not visible_lines: return - # Calculate total visible text block height - # First line is not changed by line_height scaling - total_visible_height = 0.0 - for idx, size in enumerate(visible_sizes): - if idx == 0: - total_visible_height += size.y - else: - total_visible_height += size.y * self._line_height + total_visible_height = self._block_height(len(visible_lines)) # Calculate vertical alignment offset if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: From a221614df94ee88716a1b3e7f1448054b1219a1d Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 19 Aug 2026 17:34:14 -0700 Subject: [PATCH 5/7] Revert "fix eliding" This reverts commit b271a350182ad87f9942d7363383ee8ec72d0e36. --- openpilot/system/ui/widgets/label.py | 112 ++++++++++++++++----------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index 0ddc891ed85021..a3e827321cfbf6 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -290,9 +290,6 @@ def __init__(self, self._cached_line_sizes: list[rl.Vector2] = [] self._cached_total_height: float | None = None self._cached_width: int = -1 - # visible lines depend on the rect height too, so they get their own guard - self._cached_visible: tuple[list[str], list[rl.Vector2]] = ([], []) - self._cached_visible_height: float | None = None # If max_width is set, initialize rect size for Scroller support if max_width is not None: @@ -398,7 +395,6 @@ def _update_text_cache(self, available_width: int): self._cached_text = text self._cached_width = available_width - self._cached_visible_height = None # Determine wrapping width content_width = available_width - (self._text_padding * 2) @@ -434,30 +430,20 @@ def _update_text_cache(self, available_width: int): self._cached_line_sizes.append(size) - self._cached_total_height = self._block_height(len(self._cached_line_sizes)) - - @property - def _line_px(self) -> float: - """Every line measures the same height, so line metrics are arithmetic rather than per line sums.""" - return self._cached_line_sizes[0].y if self._cached_line_sizes else self._font_size * FONT_SCALE - - def _block_height(self, lines: int) -> float: - """Height of n stacked lines. The first isn't scaled by line_height since nothing sits above it.""" - return self._line_px * (1 + (lines - 1) * self._line_height) if lines > 0 else 0.0 - - def _visible_lines(self, content_width: int) -> tuple[list[str], list[rl.Vector2]]: - """Lines that fit the rect height, with everything dropped reflowed onto the last one.""" - if self._cached_visible_height != self._rect.height: - self._cached_visible_height = self._rect.height - # always keep one line, it gets clipped by the scissor if it doesn't fit - max_lines = max(int(self._rect.height / (self._line_px * self._line_height)), 1) - lines, sizes = self._cached_wrapped_lines[:max_lines], self._cached_line_sizes[:max_lines] - if self._elide and len(self._cached_wrapped_lines) > max_lines: - # force elide so "..." shows even when the reflowed text happens to fit, to indicate more content - lines[-1] = self._elide_line(self._text_from_line(max_lines - 1), content_width, force=True) - sizes[-1] = measure_text_cached(self._font, lines[-1], self._font_size, self._spacing_pixels) - self._cached_visible = lines, sizes - return self._cached_visible + # Calculate total height + # Each line contributes its measured height * line_height (matching Label's behavior) + # This includes spacing to the next line + if self._cached_line_sizes: + # Match the rendering logic: first line doesn't get line_height scaling + total_height = 0.0 + for idx, size in enumerate(self._cached_line_sizes): + if idx == 0: + total_height += size.y + else: + total_height += size.y * self._line_height + self._cached_total_height = total_height + else: + self._cached_total_height = 0.0 def _elide_line(self, line: str, max_width: int, force: bool = False) -> str: """Elide a single line if it exceeds max_width. If force is True, always elide even if it fits.""" @@ -488,21 +474,6 @@ def _elide_line(self, line: str, max_width: int, force: bool = False) -> str: right = mid return line[:left - 1] + ellipsis if left > 0 else ellipsis - def _text_from_line(self, line_idx: int) -> str: - """The original text starting at the given wrapped line, collapsed onto one line. - - Walks the source text by non-whitespace character count instead of joining the wrapped lines, so - words that wrap_text split mid-word don't get a space inserted into them. - """ - consumed = sum(len(line) - line.count(" ") for line in self._cached_wrapped_lines[:line_idx]) - text = self.text - i = seen = 0 - while i < len(text) and seen < consumed: - if not text[i].isspace(): - seen += 1 - i += 1 - return " ".join(text[i:].split()) - def get_content_height(self, max_width: int) -> float: """ Returns the height needed for text at given max_width. @@ -532,11 +503,62 @@ def _render(self, _): if not self._cached_wrapped_lines: return - visible_lines, visible_sizes = self._visible_lines(max(int(available_width - self._text_padding * 2), 1)) + # Calculate which lines fit in the available height + visible_lines: list[str] = [] + visible_sizes: list[rl.Vector2] = [] + + current_height = 0.0 + broke_early = False + for line, size in zip( + self._cached_wrapped_lines, + self._cached_line_sizes, + strict=True): + + # Calculate height needed for this line + # Each line contributes its height * line_height (matching Label's behavior) + line_height_needed = size.y * self._line_height + + # Check if this line fits + if current_height + line_height_needed > self._rect.height: + # This line doesn't fit + if len(visible_lines) == 0: + # First line doesn't fit by height - still show it (will be clipped by scissor if needed) + # Continue to add this line below + pass + else: + # We have visible lines and this one doesn't fit - mark that we broke early + broke_early = True + break + + visible_lines.append(line) + visible_sizes.append(size) + + current_height += line_height_needed + + # If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line + if broke_early and len(visible_lines) > 0 and self._elide: + content_width = int(available_width - (self._text_padding * 2)) + if content_width <= 0: + content_width = 1 + + last_line_idx = len(visible_lines) - 1 + last_line = visible_lines[last_line_idx] + # Force elide the last line to show "..." even if it fits in width (to indicate more content) + elided = self._elide_line(last_line, content_width, force=True) + visible_lines[last_line_idx] = elided + visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels) + if not visible_lines: return - total_visible_height = self._block_height(len(visible_lines)) + # Calculate total visible text block height + # First line is not changed by line_height scaling + total_visible_height = 0.0 + for idx, size in enumerate(visible_sizes): + if idx == 0: + total_visible_height += size.y + else: + total_visible_height += size.y * self._line_height # Calculate vertical alignment offset if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: From 6336788f4c26b6cbb9d342a90e80ffbe59524a45 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 21 Aug 2026 15:32:06 -0700 Subject: [PATCH 6/7] clean up --- openpilot/selfdrive/ui/mici/widgets/button.py | 12 ++---------- test_buttons.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 5db35a5c66c2cc..e2912d00cf4b62 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -150,13 +150,12 @@ def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None) def _title_width_hint(self) -> int: - # Single line if scrolling, so hide behind icon if exists + # A value moves the title to the top, where it shares space with the icon icon_size = self._txt_icon.width if self._txt_icon and self.value else 0 return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size) def _subtitle_width_hint(self) -> int: - # Single line if scrolling, so hide behind icon if exists - icon_size = self._txt_icon.width if self._txt_icon else 0 + # Bottom aligned, so it sits below the icon return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) def _get_label_font_size(self): @@ -239,7 +238,6 @@ def _draw_content(self, btn_y: float): if self.value: label_y = label_rect.y + self._label.get_content_height(int(label_rect.width)) - print(label_y, btn_y, self._label.rect.x, self._label.rect.y, self._label.rect.width, self._label.rect.height) sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height) self._sub_label.render(sub_label_rect) @@ -318,9 +316,6 @@ def __init__(self, text: str, options: list[str], toggle_callback: Callable | No self.set_value(self._options[0]) - # def _width_hint(self) -> int: - # return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width) - def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) cur_idx = self._options.index(self.value) @@ -369,9 +364,6 @@ def __init__(self, *args, **kwargs): def LABEL_VERTICAL_PADDING(self): return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18 - # def _width_hint(self) -> int: - # return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) - def _get_label_font_size(self): return 36 diff --git a/test_buttons.py b/test_buttons.py index 547fac7b951e89..122d9144a24284 100755 --- a/test_buttons.py +++ b/test_buttons.py @@ -9,6 +9,7 @@ # from openpilot.selfdrive.ui.layouts.main import MainLayout # from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle, GreyBigButton +from openpilot.system.ui.mici_setup import BigPillButton from openpilot.system.ui.widgets.scroller import Scroller from openpilot.selfdrive.ui.ui_state import ui_state @@ -66,6 +67,15 @@ def __init__(self): BigButton("release3-staging", "", txt_up_to_date, scroll=True), # BigButton: title only, no icon, scrolling title (software.py branch picker, other branches) BigButton("master-ci", "", None, scroll=True), + # BigButton: title, long value, no icon (software.py TargetBranchButton, value is an arbitrary branch) + BigButton("target branch", "a-really-long-feature-branch"), + + # BigPillButton: centered middle aligned title, no icon or value (mici_setup.py) + BigPillButton("next"), + # BigPillButton: multi-line title, disabled background (mici_setup.py) + BigPillButton("connect to\ncontinue", disabled_background=True), + # BigPillButton: green variant (mici_setup.py) + BigPillButton("install openpilot", green=True), # BigToggle: title only, off (developer.py) BigToggle("joystick debug mode"), From b5f9a721e5b9ad3c60db0c22050acb7b771b55d0 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 21 Aug 2026 15:36:23 -0700 Subject: [PATCH 7/7] clean up --- test_buttons.py | 122 ------------------------------------------------ 1 file changed, 122 deletions(-) delete mode 100755 test_buttons.py diff --git a/test_buttons.py b/test_buttons.py deleted file mode 100755 index 122d9144a24284..00000000000000 --- a/test_buttons.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -import os -import time - -from openpilot.cereal import messaging -from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.common.realtime import Priority, config_realtime_process, set_core_affinity -from openpilot.system.ui.lib.application import gui_app -# from openpilot.selfdrive.ui.layouts.main import MainLayout -# from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle, GreyBigButton -from openpilot.system.ui.mici_setup import BigPillButton -from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.selfdrive.ui.ui_state import ui_state - -BIG_UI = gui_app.big_ui() - - -class Font64BigButton(BigButton): - """SettingsBigButton (settings.py) and PairBigButton (device.py) both bump the title to 64""" - - def _get_label_font_size(self): - return 64 - - -class MainLayout(Scroller): - def __init__(self): - super().__init__() - - # icons at the sizes the UI actually loads them at - txt_settings = gui_app.texture("icons_mici/settings.png", 64, 64) - txt_info = gui_app.texture("icons_mici/settings/device/info.png", 64, 64) - txt_cameras = gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64) - txt_comma = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) - txt_reboot = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70) - txt_update = gui_app.texture("icons_mici/settings/device/update.png", 64, 75) - txt_up_to_date = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) - txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) - txt_wifi = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - txt_warning = gui_app.texture("icons_mici/setup/warning.png", 64, 64) - txt_warning_58 = gui_app.texture("icons_mici/setup/warning.png", 64, 58) - txt_green_dm = gui_app.texture("icons_mici/setup/green_dm.png", 64, 64) - txt_factory_reset = gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64) - - self._scroller.add_widgets([ - # BigButton: title, icon, no value (settings.py SettingsBigButton) - Font64BigButton("toggles", "", txt_settings), - # BigButton: single line title, icon, no value (device.py regulatory info, network_layout.py tethering password) - BigButton("regulatory info", "", txt_info), - # BigButton: multi-line title, icon, no value (device.py) - BigButton("driver\ncamera preview", "", txt_cameras), - # BigButton: title, value, icon (device.py PairBigButton) - Font64BigButton("pair", "connect.comma.ai", txt_comma), - # BigButton: title, value, icon (developer.py SSH keys) - BigButton("SSH keys", "Not set", txt_ssh), - # BigButton: title, value, icon (software.py InstallUpdateButton) - BigButton("install update", "0.10.1 (release-chestnut)", txt_reboot), - # BigButton: no title, value, icon (software.py CheckUpdateButton, title cleared when a value is set) - BigButton("", "updater failed\nto respond", txt_update), - # BigButton: title, value, no icon (network_layout.py apn settings) - BigButton("apn settings", "edit"), - # BigButton: no title, value, no icon (wifi_ui.py ScanningButton) - BigButton("", "searching for networks"), - # BigButton: title, value, icon, scrolling title (network/__init__.py WifiNetworkButton) - BigButton("a-really-long-network-name", "192.168.100.100", txt_wifi, scroll=True), - # BigButton: title, icon, no value, scrolling title (software.py branch picker, current target) - BigButton("release3-staging", "", txt_up_to_date, scroll=True), - # BigButton: title only, no icon, scrolling title (software.py branch picker, other branches) - BigButton("master-ci", "", None, scroll=True), - # BigButton: title, long value, no icon (software.py TargetBranchButton, value is an arbitrary branch) - BigButton("target branch", "a-really-long-feature-branch"), - - # BigPillButton: centered middle aligned title, no icon or value (mici_setup.py) - BigPillButton("next"), - # BigPillButton: multi-line title, disabled background (mici_setup.py) - BigPillButton("connect to\ncontinue", disabled_background=True), - # BigPillButton: green variant (mici_setup.py) - BigPillButton("install openpilot", green=True), - - # BigToggle: title only, off (developer.py) - BigToggle("joystick debug mode"), - # BigToggle: title only, on, wraps to two lines (developer.py) - BigToggle("longitudinal maneuver mode", initial_state=True), - # BigMultiToggle: title + the current option as the value (network_layout.py) - BigMultiToggle("network usage", ["default", "metered", "unmetered"]), - # BigMultiToggle: longer title (toggles.py BigMultiParamToggle) - BigMultiToggle("driving personality", ["aggressive", "standard", "relaxed"]), - - # GreyBigButton: multi-line title, single line value, icon (toggles.py, onboarding.py, mici_setup.py headers) - GreyBigButton("enabling\nexperimental mode", "scroll to continue", txt_warning), - # GreyBigButton: single line title, single line value, icon (developer.py, onboarding.py) - GreyBigButton("cabin camera data", "do you want to share video data for training?", txt_green_dm), - # GreyBigButton: single line title, multi-line value, icon (mici_reset.py, mici_updater.py, onboarding.py) - GreyBigButton("factory reset", "resetting erases\nall user content & data", txt_factory_reset), - # GreyBigButton: same shape with the shorter icon (mici_setup.py FailedPage, description defaulted) - GreyBigButton("download failed", "swipe down to go\nback and try again", txt_warning_58), - # GreyBigButton: title only (toggles.py section headers) - GreyBigButton("End-to-End Longitudinal Control"), - # GreyBigButton: no title, wrapping value, no icon (toggles.py body copy, BigDialog) - GreyBigButton("", "openpilot will drive as it thinks a human would, including stopping for red lights and stop signs."), - # GreyBigButton: no title, explicit multi-line value, no icon (mici_reset.py, mici_setup.py reason card) - GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"), - ]) - - - -def main(): - cores = {5, } - # above plannerd and radard - config_realtime_process(0, Priority.CTRL_HIGH) - - gui_app.init_window("UI") - ml = MainLayout() - gui_app.push_widget(ml) - - - for should_render, frame_time, cpu_time in gui_app.render(): - ui_state.update() - - -if __name__ == "__main__": - main()