""" Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ """ import math import time import pyray as rl from typing import Union from enum import Enum from collections.abc import Callable from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import MiciLabel from openpilot.system.ui.widgets.scroller import DO_ZOOM from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.common.filter_simple import BounceFilter try: from openpilot.common.params import Params except ImportError: Params = None try: from openpilot.iqpilot.ui.theme import NeonTheme except ImportError: # Fallback theme if iqpilot layer not available class _FallbackTheme: def glow(self, alpha=255): return rl.Color(0, 255, 245, alpha) def glow_mid(self, alpha=130): return rl.Color(0, 255, 245, alpha) def glow_outer(self, alpha=45): return rl.Color(0, 255, 245, alpha) def bg(self): return rl.Color(0, 26, 25, 255) def bg_pressed(self): return rl.Color(0, 33, 32, 255) NeonTheme = _FallbackTheme() SCROLLING_SPEED_PX_S = 50 COMPLICATION_SIZE = 36 LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) LABEL_HORIZONTAL_PADDING = 40 COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255) PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07 class ScrollState(Enum): PRE_SCROLL = 0 SCROLLING = 1 POST_SCROLL = 2 class BigCircleButton(Widget): def __init__(self, icon: str, red: bool = False, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)): super().__init__() self._red = red self._icon_offset = icon_offset # State self.set_rect(rl.Rectangle(0, 0, 180, 180)) self._press_state_enabled = True self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) # Icons self._txt_icon = gui_app.texture(icon, *icon_size) self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180) self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180) self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180) self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180) def set_enable_pressed_state(self, pressed: bool): self._press_state_enabled = pressed def _render(self, _): # draw background txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg if not self.enabled: txt_bg = self._txt_btn_disabled_bg elif self.is_pressed and self._press_state_enabled: txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0) btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) # draw icon icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2 + self._icon_offset[0]), int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2 + self._icon_offset[1]), icon_color) class BigCircleToggle(BigCircleButton): def __init__(self, icon: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)): super().__init__(icon, False, icon_size=icon_size, icon_offset=icon_offset) self._toggle_callback = toggle_callback # State self._checked = False # Icons self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66) self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66) def set_checked(self, checked: bool): self._checked = checked def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) self._checked = not self._checked if self._toggle_callback: self._toggle_callback(self._checked) def _render(self, _): super()._render(_) # draw status icon rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled, int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2), int(self._rect.y + 5), rl.WHITE) class BigButton(Widget): """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", icon_size: tuple[int, int] = (64, 64)): super().__init__() self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value self._icon_size = icon_size self.set_icon(icon) self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._rotate_icon_t: float | None = None self._label_font = gui_app.font(FontWeight.DISPLAY) self._value_font = gui_app.font(FontWeight.ROMAN) self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2), font_weight=FontWeight.DISPLAY, color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True) self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2), font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True) self._load_images() # internal state self._scroll_offset = 0 # in pixels self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width self._scroll_timer = 0 self._scroll_state = ScrollState.PRE_SCROLL def set_icon(self, icon: Union[str, rl.Texture]): self._txt_icon = gui_app.texture(icon, *self._icon_size) if isinstance(icon, str) and len(icon) else icon def set_rotate_icon(self, rotate: bool): if rotate and self._rotate_icon_t is not None: return self._rotate_icon_t = rl.get_time() if rotate else None def _load_images(self): self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180) self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180) self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180) self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180) def _get_label_font_size(self): if len(self.text) < 12: font_size = 64 elif len(self.text) < 17: font_size = 48 elif len(self.text) < 20: font_size = 42 else: font_size = 36 if self.value: font_size -= 20 return font_size def set_text(self, text: str): self.text = text self._label.set_text(text) def set_value(self, value: str): self.value = value self._sub_label.set_text(value) def get_value(self) -> str: return self.value def get_text(self): return self.text def _update_state(self): # hold on text for a bit, scroll, hold again, reset if self._needs_scroll: """`dt` should be seconds since last frame (rl.get_frame_time()).""" # TODO: this comment is generated by GPT, prob wrong and misused dt = rl.get_frame_time() self._scroll_timer += dt if self._scroll_state == ScrollState.PRE_SCROLL: if self._scroll_timer < 0.5: return self._scroll_state = ScrollState.SCROLLING self._scroll_timer = 0 elif self._scroll_state == ScrollState.SCROLLING: self._scroll_offset -= SCROLLING_SPEED_PX_S * dt # reset when text has completely left the button + 50 px gap # TODO: use global constant for 30+30 px gap # TODO: add std Widget padding option integrated into the self._rect full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30 if self._scroll_offset < (self._rect.width - full_len): self._scroll_state = ScrollState.POST_SCROLL self._scroll_timer = 0 elif self._scroll_state == ScrollState.POST_SCROLL: # wait for a bit before starting to scroll again if self._scroll_timer < 0.75: return self._scroll_state = ScrollState.PRE_SCROLL self._scroll_timer = 0 self._scroll_offset = 0 def _render(self, _): # draw _txt_default_bg txt_bg = self._txt_default_bg if not self.enabled: txt_bg = self._txt_disabled_bg elif self.is_pressed: txt_bg = self._txt_hover_bg scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0) btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) # LABEL ------------------------------------------------------------------ lx = self._rect.x + LABEL_HORIZONTAL_PADDING ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2 if self.value: self._sub_label.set_position(lx, ly) ly -= self._sub_label.font_size + 9 self._sub_label.render() label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) self._label.set_color(label_color) self._label.set_position(lx, ly) self._label.render() # ICON ------------------------------------------------------------------- if self._txt_icon: rotation = 0 if self._rotate_icon_t is not None: rotation = (rl.get_time() - self._rotate_icon_t) * 180 # drop top right with 30px padding x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2 y = self._rect.y + 30 + self._txt_icon.height / 2 source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height) dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height) origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2) rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE) class BigToggle(BigButton): def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): super().__init__(text, value, "") self._checked = initial_state self._toggle_callback = toggle_callback self._label.set_font_size(48) def _load_images(self): super()._load_images() self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66) self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66) def set_checked(self, checked: bool): self._checked = checked def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) self._checked = not self._checked if self._toggle_callback: self._toggle_callback(self._checked) def _draw_pill(self, x: float, y: float, checked: bool): # draw toggle icon top right if checked: rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE) else: rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE) def _render(self, _): super()._render(_) x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width y = self._rect.y self._draw_pill(x, y, self._checked) 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) assert len(options) > 0 self._options = options self._select_callback = select_callback self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)) # TODO: why isn't this automatic? self._label.set_font_size(self._get_label_font_size()) self.set_value(self._options[0]) def _get_label_font_size(self): font_size = super()._get_label_font_size() return font_size - 6 def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) cur_idx = self._options.index(self.value) new_idx = (cur_idx + 1) % len(self._options) self.set_value(self._options[new_idx]) if self._select_callback: self._select_callback(self.value) def _render(self, _): BigButton._render(self, _) checked_idx = self._options.index(self.value) x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width y = self._rect.y for i in range(len(self._options)): self._draw_pill(x, y, checked_idx == i) y += 35 class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, select_callback: Callable | None = None): super().__init__(text, options, toggle_callback, select_callback) self._param = param self._params = Params() self._load_value() def _load_value(self): self.set_value(self._options[self._params.get(self._param) or 0]) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) new_idx = self._options.index(self.value) self._params.put_nonblocking(self._param, new_idx) class BigParamControl(BigToggle): def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): super().__init__(text, "", toggle_callback=toggle_callback) self.param = param self.params = Params() self.set_checked(self.params.get_bool(self.param, False)) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) self.params.put_bool(self.param, self._checked) def refresh(self): self.set_checked(self.params.get_bool(self.param, False)) # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: str, param: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)): super().__init__(icon, toggle_callback, icon_size=icon_size, icon_offset=icon_offset) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) self.params.put_bool(self._param, self._checked) def refresh(self): self.set_checked(self.params.get_bool(self._param, False)) _CHIP_BG = rl.Color(0x30, 0x30, 0x30, 230) _CHIP_TEXT_COLOR = rl.Color(0xDD, 0xDD, 0xDD, 255) _CHIP_H = 28 _CHIP_FONT_SIZE = 18 _CHIP_H_PAD = 10 _CHIP_V_PAD = 5 _CHIP_RADIUS = 0.5 _CHIP_SPACING = 8 _NEON_CORNER_ROUND = 0.28 _NEON_CARD_PAD = 18 def _draw_neon_glow_halo(rect: rl.Rectangle, intensity: float = 1.0): segs = 8 roundness = _NEON_CORNER_ROUND for expand, alpha in [ (14, int(18 * intensity)), (12, int(28 * intensity)), (10, int(40 * intensity)), (8, int(55 * intensity)), (6, int(72 * intensity)), (4, int(95 * intensity)), (2, int(120 * intensity)), ]: ex = rl.Rectangle( rect.x - expand, rect.y - expand, rect.width + expand * 2, rect.height + expand * 2, ) rl.draw_rectangle_rounded(ex, roundness, segs, NeonTheme.glow_outer(alpha)) def _draw_neon_glow_border(rect: rl.Rectangle, intensity: float = 1.0): segs = 8 roundness = _NEON_CORNER_ROUND rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 2.5, NeonTheme.glow(int(255 * intensity))) rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 1.0, rl.Color(255, 255, 255, int(180 * intensity))) def _neon_title_font_size(text: str, has_chips: bool) -> int: if len(text) < 10: size = 52 elif len(text) < 14: size = 42 elif len(text) < 18: size = 36 else: size = 30 if has_chips: size = min(size, 36) return size class NeonBigButton(Widget): CARD_W = 310 CARD_H = 160 def __init__(self, title: str, chips: list[str] | None = None, click_callback: Callable | None = None): super().__init__() self._title_text = title self._chips: list[str] = chips or [] self._click_callback = click_callback self._born: float = time.monotonic() self.set_rect(rl.Rectangle(0, 0, self.CARD_W, self.CARD_H)) self._label = MiciLabel( title, font_size=_neon_title_font_size(title, bool(chips)), width=self.CARD_W - _NEON_CARD_PAD * 2, font_weight=FontWeight.DISPLAY, color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, wrap_text=True, elide_right=False, ) self._chip_font = gui_app.font(FontWeight.MEDIUM) def set_chips(self, chips: list[str]): self._chips = chips # Re-size title now that we know if chips are present self._label.set_font_size(_neon_title_font_size(self._title_text, bool(chips))) def set_click_callback(self, cb: Callable | None): self._click_callback = cb def _glow_intensity(self) -> float: t = time.monotonic() - self._born return 0.65 + 0.35 * (0.5 + 0.5 * math.sin(t * 5.0)) def _render_chips(self, start_x: float, start_y: float): x = start_x for chip in self._chips: text_w = int(measure_text_cached(self._chip_font, chip, _CHIP_FONT_SIZE).x) w = text_w + _CHIP_H_PAD * 2 rl.draw_rectangle_rounded(rl.Rectangle(x, start_y, w, _CHIP_H), _CHIP_RADIUS, 4, _CHIP_BG) text_y = start_y + (_CHIP_H - _CHIP_FONT_SIZE) // 2 rl.draw_text_ex( self._chip_font, chip, rl.Vector2(x + _CHIP_H_PAD, text_y), _CHIP_FONT_SIZE, 0, _CHIP_TEXT_COLOR, ) x += w + _CHIP_SPACING def _render(self, rect: rl.Rectangle) -> None: intensity = self._glow_intensity() _draw_neon_glow_halo(rect, intensity) bg = NeonTheme.bg_pressed() if self.is_pressed else NeonTheme.bg() rl.draw_rectangle_rounded(rect, _NEON_CORNER_ROUND, 6, bg) _draw_neon_glow_border(rect, intensity) chips_h = (_CHIP_H + 6) if self._chips else 0 title_rect = rl.Rectangle( rect.x + _NEON_CARD_PAD, rect.y + _NEON_CARD_PAD, rect.width - _NEON_CARD_PAD * 2, rect.height - _NEON_CARD_PAD - chips_h, ) label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) self._label.set_color(label_color) self._label.render(title_rect) if self._chips: chip_y = rect.y + rect.height - _CHIP_H - _NEON_CARD_PAD // 2 self._render_chips(rect.x + _NEON_CARD_PAD, chip_y) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) if self._click_callback: self._click_callback() class NeonBigParamToggle(NeonBigButton): def __init__(self, title: str, param: str, sub_chips: list[str] | None = None, toggle_callback: Callable | None = None): super().__init__(title, chips=["disabled"]) # pre-set so layout reserves chip space self._param = param self._params = Params() if Params else None self._sub_chips: list[str] = sub_chips or [] self._toggle_callback = toggle_callback self._checked: bool = False self._load_value() self._rebuild_chips() def set_sub_chips(self, sub_chips: list[str]): self._sub_chips = sub_chips self._rebuild_chips() def _load_value(self): if self._params: self._checked = self._params.get_bool(self._param, False) def _rebuild_chips(self): state = "enabled" if self._checked else "disabled" self.set_chips([state] + self._sub_chips) def _glow_intensity(self) -> float: return super()._glow_intensity() if self._checked else 0.25 def _render(self, rect: rl.Rectangle) -> None: super()._render(rect) if not self._checked: rl.draw_rectangle_rounded(rect, _NEON_CORNER_ROUND, 6, rl.Color(0, 0, 0, 120)) def _handle_mouse_release(self, mouse_pos: MousePos): self._checked = not self._checked if self._params: self._params.put_bool(self._param, self._checked) self._rebuild_chips() if self._toggle_callback: self._toggle_callback(self._checked) def refresh(self): self._load_value() self._rebuild_chips() class NeonBigCircleParamControl(BigCircleParamControl): def __init__(self, icon: str, param: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)): super().__init__(icon, param, toggle_callback=toggle_callback, icon_size=icon_size, icon_offset=icon_offset) self._born = time.monotonic() def _glow_intensity(self) -> float: if not self._checked: return 0.25 t = time.monotonic() - self._born return 0.65 + 0.35 * (0.5 + 0.5 * math.sin(t * 5.0)) def _render(self, rect: rl.Rectangle): intensity = self._glow_intensity() roundness = 1.0 segs = 12 for expand, alpha in [ (12, int(14 * intensity)), (10, int(22 * intensity)), (8, int(35 * intensity)), (6, int(50 * intensity)), (4, int(70 * intensity)), (2, int(95 * intensity)), ]: ex = rl.Rectangle(rect.x - expand, rect.y - expand, rect.width + expand * 2, rect.height + expand * 2) rl.draw_rectangle_rounded(ex, roundness, segs, NeonTheme.glow_outer(alpha)) super()._render(rect) rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 2.5, NeonTheme.glow(int(255 * intensity))) rl.draw_rectangle_rounded_lines_ex(rect, roundness, segs, 1.0, rl.Color(255, 255, 255, int(160 * intensity))) class NeonBigMultiToggle(NeonBigButton): def __init__(self, title: str, options: list[str], select_callback: Callable | None = None): assert len(options) > 0 super().__init__(title, chips=[options[0]]) self._options = options self._value = options[0] self._select_callback = select_callback def set_value(self, value: str): if value in self._options: self._value = value else: self._value = self._options[0] self.set_chips([self._value]) def get_value(self) -> str: return self._value def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) idx = self._options.index(self._value) self._value = self._options[(idx + 1) % len(self._options)] self.set_chips([self._value]) if self._select_callback: self._select_callback(self._value) class NeonBigMultiParamToggle(NeonBigMultiToggle): def __init__(self, title: str, param: str, options: list[str], select_callback: Callable | None = None): super().__init__(title, options, select_callback) self._param = param self._params = Params() if Params else None self._load_value() def _load_value(self): if self._params: try: idx = int(self._params.get(self._param) or 0) idx = max(0, min(idx, len(self._options) - 1)) except (TypeError, ValueError): idx = 0 self.set_value(self._options[idx]) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) idx = self._options.index(self._value) if self._params: self._params.put_nonblocking(self._param, idx) def refresh(self): self._load_value() class NeonMappedParamToggle(NeonBigMultiToggle): def __init__(self, title: str, param: str, options: list[str], values: list[int], select_callback: Callable | None = None): super().__init__(title, options, select_callback) assert len(options) == len(values) self._param = param self._values = values self._params = Params() if Params else None self._load_value() def _load_value(self): if self._params: try: current = int(self._params.get(self._param, return_default=True) or 0) idx = self._values.index(current) except (TypeError, ValueError): idx = 0 self.set_value(self._options[idx]) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) idx = self._options.index(self._value) if self._params: self._params.put_nonblocking(self._param, self._values[idx]) def refresh(self): self._load_value() class NeonFloatMappedParamToggle(NeonBigMultiToggle): def __init__(self, title: str, param: str, options: list[str], values: list[float], select_callback: Callable | None = None): super().__init__(title, options, select_callback) assert len(options) == len(values) self._param = param self._values = values self._params = Params() if Params else None self._load_value() def _load_value(self): if self._params: try: current_val = float(self._params.get(self._param, return_default=True) or 0) idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - current_val)) except (TypeError, ValueError): idx = 1 if len(self._values) > 1 else 0 self.set_value(self._options[idx]) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) idx = self._options.index(self._value) if self._params: self._params.put_nonblocking(self._param, str(self._values[idx])) def refresh(self): self._load_value() class DrumPickerButton(NeonBigButton): def __init__(self, title: str, options: list[str]): super().__init__(title, chips=[""]) self._options = options def _read_current(self) -> str: raise NotImplementedError def _write_value(self, value: str): raise NotImplementedError def refresh(self): self.set_chips([self._read_current()]) def _on_picked(self, value: str): self._write_value(value) self.set_chips([value]) def _handle_mouse_release(self, mouse_pos: MousePos): # Bypass NeonBigButton cycle — open drum picker instead Widget._handle_mouse_release(self, mouse_pos) if getattr(self, '_swiping_away', False): return from openpilot.selfdrive.ui.mici.widgets.drum_picker import DrumPickerDialog from openpilot.system.ui.lib.application import gui_app as _app dlg = DrumPickerDialog( title=self._title_text.lower(), options=self._options, current=self._read_current(), confirm_callback=self._on_picked, ) _app.set_modal_overlay(dlg) class DrumParamButton(DrumPickerButton): def __init__(self, title: str, param: str, options: list[str]): self._param = param self._params = Params() if Params else None super().__init__(title, options) self.refresh() def _read_current(self) -> str: try: idx = int(self._params.get(self._param, return_default=True) or 0) idx = max(0, min(idx, len(self._options) - 1)) except (TypeError, ValueError): idx = 0 return self._options[idx] def _write_value(self, value: str): if value in self._options and self._params: self._params.put_nonblocking(self._param, self._options.index(value)) class DrumMappedParamButton(DrumPickerButton): def __init__(self, title: str, param: str, options: list[str], values: list[int]): assert len(options) == len(values) self._param = param self._values = values self._params = Params() if Params else None super().__init__(title, options) self.refresh() def _read_current(self) -> str: try: raw = int(self._params.get(self._param, return_default=True) or 0) idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - raw)) except (TypeError, ValueError): idx = 0 return self._options[idx] def _write_value(self, value: str): if value in self._options and self._params: idx = self._options.index(value) self._params.put_nonblocking(self._param, int(self._values[idx])) class DrumFloatMappedParamButton(DrumPickerButton): def __init__(self, title: str, param: str, options: list[str], values: list[float]): assert len(options) == len(values) self._param = param self._values = values self._params = Params() if Params else None super().__init__(title, options) self.refresh() def _read_current(self) -> str: try: raw = float(self._params.get(self._param, return_default=True) or 0) idx = min(range(len(self._values)), key=lambda i: abs(self._values[i] - raw)) except (TypeError, ValueError): idx = 0 return self._options[idx] def _write_value(self, value: str): if value in self._options and self._params: idx = self._options.index(value) self._params.put_nonblocking(self._param, float(self._values[idx]))