import os import pyray as rl from collections.abc import Callable from abc import ABC from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType from openpilot.common.filter_simple import FirstOrderFilter ITEM_BASE_WIDTH = 600 ITEM_BASE_HEIGHT = 170 ITEM_PADDING = 20 ITEM_TEXT_FONT_SIZE = 50 ITEM_TEXT_COLOR = rl.WHITE ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255) ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255) ITEM_DESC_FONT_SIZE = 40 ITEM_DESC_V_OFFSET = 140 RIGHT_ITEM_PADDING = 20 ICON_SIZE = 80 BUTTON_WIDTH = 250 BUTTON_HEIGHT = 100 BUTTON_BORDER_RADIUS = 50 BUTTON_FONT_SIZE = 35 BUTTON_FONT_WEIGHT = FontWeight.MEDIUM TEXT_PADDING = 20 def _resolve_value(value, default=""): if callable(value): return value() return value if value is not None else default # Abstract base class for right-side items class ItemAction(Widget, ABC): def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True): super().__init__() self.set_rect(rl.Rectangle(0, 0, width, 0)) self._enabled_source = enabled def get_width_hint(self) -> float: # Return's action ideal width, 0 means use full width return self._rect.width def set_enabled(self, enabled: bool | Callable[[], bool]): self._enabled_source = enabled @property def enabled(self): return _resolve_value(self._enabled_source, False) class ToggleAction(ItemAction): def __init__( self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, callback: Callable[[bool], None] | None = None ): super().__init__(width, enabled) self.toggle = Toggle(initial_state=initial_state, callback=callback) def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) self.toggle.set_touch_valid_callback(touch_callback) def _render(self, rect: rl.Rectangle) -> bool: self.toggle.set_enabled(self.enabled) clicked = self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT)) return bool(clicked) def set_state(self, state: bool): self.toggle.set_state(state) def get_state(self) -> bool: return self.toggle.get_state() class ButtonAction(ItemAction): def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True): super().__init__(width, enabled) self._text_source = text self._value_source: str | Callable[[], str] | None = None self._pressed = False self._font = gui_app.font(FontWeight.NORMAL) def pressed(): self._pressed = True self._button = Button( self.text, font_size=BUTTON_FONT_SIZE, font_weight=BUTTON_FONT_WEIGHT, button_style=ButtonStyle.LIST_ACTION, border_radius=BUTTON_BORDER_RADIUS, click_callback=pressed, text_padding=0, ) 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) def set_text(self, text: str | Callable[[], str]): self._text_source = text def set_value(self, value: str | Callable[[], str]): self._value_source = value @property def text(self): return _resolve_value(self._text_source, tr("Error")) @property def value(self): return _resolve_value(self._value_source, "") 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.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: 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 self._pressed = False return pressed class TextAction(ItemAction): def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True): self._text_source = text self.color = color self._font = gui_app.font(FontWeight.NORMAL) initial_text = _resolve_value(text, "") text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x super().__init__(int(text_width + TEXT_PADDING), enabled) @property def text(self): return _resolve_value(self._text_source, tr("Error")) def get_width_hint(self) -> float: text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x return text_width + TEXT_PADDING def _render(self, rect: rl.Rectangle) -> bool: gui_label( self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color, font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, ) return False def set_text(self, text: str | Callable[[], str]): self._text_source = text class DualButtonAction(ItemAction): def __init__( self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None, right_callback: Callable = None, enabled: bool | Callable[[], bool] = True, ): super().__init__(width=0, enabled=enabled) # Width 0 means use full width self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0) self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0) def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) self.left_button.set_touch_valid_callback(touch_callback) self.right_button.set_touch_valid_callback(touch_callback) def _render(self, rect: rl.Rectangle): button_spacing = 30 button_height = 120 button_width = (rect.width - button_spacing) / 2 button_y = rect.y + (rect.height - button_height) / 2 left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height) right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height) # expand one to full width if other is not visible if not self.left_button.is_visible: right_rect.x = rect.x right_rect.width = rect.width elif not self.right_button.is_visible: left_rect.width = rect.width # Render buttons self.left_button.render(left_rect) self.right_button.render(right_rect) class MultipleButtonAction(ItemAction): def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None): super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=True) self.buttons = buttons self.button_width = button_width self.selected_button = selected_index self.callback = callback self._font = gui_app.font(FontWeight.MEDIUM) def set_selected_button(self, index: int): if 0 <= index < len(self.buttons): self.selected_button = index def get_selected_button(self) -> int: return self.selected_button def _render(self, rect: rl.Rectangle): spacing = RIGHT_ITEM_PADDING button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 for i, _text in enumerate(self.buttons): button_x = rect.x + i * (self.button_width + spacing) button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT) # Check button state mouse_pos = rl.get_mouse_position() is_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled and self.is_pressed is_selected = i == self.selected_button # Button colors if is_selected: bg_color = rl.Color(51, 171, 76, 255) # Green elif is_pressed: bg_color = rl.Color(74, 74, 74, 255) # Dark gray else: bg_color = rl.Color(57, 57, 57, 255) # Gray if not self.enabled: bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim # Draw button rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) # Draw text text = _resolve_value(_text, "") text_size = measure_text_cached(self._font, text, 40) text_x = button_x + (self.button_width - text_size.x) / 2 text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2 text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255) rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) def _handle_mouse_release(self, mouse_pos: MousePos): spacing = RIGHT_ITEM_PADDING button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2 for i, _ in enumerate(self.buttons): button_x = self._rect.x + i * (self.button_width + spacing) button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT) if rl.check_collision_point_rec(mouse_pos, button_rect): self.selected_button = i if self.callback: self.callback(i) class CategoryButtonsAction(ItemAction): def __init__(self, buttons: list[tuple[str | Callable[[], str], Callable]], button_width: int = 150, enabled: bool | Callable[[], bool] = True): # Calculate width_hint based on actual text content (matches _render logic) padding = 20 # 10px per side total_text_width = 0 for label, _ in buttons: text = _resolve_value(label, "") text_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), text, BUTTON_FONT_SIZE) total_text_width += text_size.x + padding total_width = total_text_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING super().__init__(width=total_width, enabled=enabled) self.buttons = buttons self._font = gui_app.font(FontWeight.MEDIUM) def _render(self, rect: rl.Rectangle): spacing = RIGHT_ITEM_PADDING button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 # Calculate per-button width based on text + padding, scaled to fit padding = 20 # 10px per side button_widths = [] ideal_total = 0 for label, _ in self.buttons: text = _resolve_value(label, "") text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) ideal_width = text_size.x + padding button_widths.append(ideal_width) ideal_total += ideal_width ideal_total += (len(self.buttons) - 1) * spacing # Scale proportionally if total exceeds available space if ideal_total > rect.width: scale = rect.width / ideal_total button_widths = [max(80, w * scale) for w in button_widths] else: # Ensure minimum width button_widths = [max(80, w) for w in button_widths] # Start from left edge of rect - use cumulative sum for correct positioning current_button_x = rect.x for i, (label, _) in enumerate(self.buttons): btn_w = button_widths[i] button_rect = rl.Rectangle(current_button_x, button_y, btn_w, BUTTON_HEIGHT) # Check button state mouse_pos = rl.get_mouse_position() is_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled and self.is_pressed bg_color = rl.Color(57, 57, 57, 255) # Gray if is_pressed: bg_color = rl.Color(74, 74, 74, 255) # Dark gray if not self.enabled: bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) # Draw text with proper centering text = _resolve_value(label, "") text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) text_x = current_button_x + (btn_w - text_size.x) / 2 text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2 text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255) rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color) # Advance position for next button current_button_x += btn_w + spacing def _handle_mouse_release(self, mouse_pos: MousePos): spacing = RIGHT_ITEM_PADDING button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2 # Calculate per-button width based on text + padding, scaled to fit (same as _render) padding = 20 # 10px per side button_widths = [] ideal_total = 0 for label, _ in self.buttons: text = _resolve_value(label, "") text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) ideal_width = text_size.x + padding button_widths.append(ideal_width) ideal_total += ideal_width ideal_total += (len(self.buttons) - 1) * spacing # Scale proportionally if total exceeds available space if ideal_total > self._rect.width: scale = self._rect.width / ideal_total button_widths = [max(80, w * scale) for w in button_widths] else: # Ensure minimum width button_widths = [max(80, w) for w in button_widths] # Start from left edge of rect - use cumulative sum for correct positioning current_button_x = self._rect.x for i, (_, callback) in enumerate(self.buttons): btn_w = button_widths[i] button_rect = rl.Rectangle(current_button_x, button_y, btn_w, BUTTON_HEIGHT) if rl.check_collision_point_rec(mouse_pos, button_rect): if callback: callback() # Advance position for next button current_button_x += btn_w + spacing def category_buttons_item( title: str | Callable[[], str], buttons: list[tuple[str | Callable[[], str], Callable]], description: str | Callable[[], str] | None = None, icon: str = "", button_width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True, starpilot_icon: bool = False, ) -> "ListItem": action = CategoryButtonsAction(buttons, button_width, enabled=enabled) icon_to_use = "" if starpilot_icon else icon item = ListItem(title=title, description=description, icon=icon_to_use, action_item=action) if icon and starpilot_icon: item.set_icon(icon, starpilot=True) return item class ListItem(Widget): def __init__( self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None, description_visible: bool = False, callback: Callable | None = None, action_item: ItemAction | None = None, ): super().__init__() self._title = title self.set_icon(icon) self._description = description self.description_visible = description_visible self.set_click_callback(callback) self.description_opened_callback: Callable | None = None self.action_item = action_item self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT)) self._font = gui_app.font(FontWeight.NORMAL) self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE}, text_color=ITEM_DESC_TEXT_COLOR) self._parse_description(self.description) # Cached properties for performance self._prev_description: str | None = self.description def show_event(self): self._set_description_visible(False) def set_description_opened_callback(self, callback: Callable) -> None: self.description_opened_callback = callback def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: super().set_touch_valid_callback(touch_callback) if self.action_item: self.action_item.set_touch_valid_callback(touch_callback) def set_parent_rect(self, parent_rect: rl.Rectangle): super().set_parent_rect(parent_rect) self._rect.width = parent_rect.width def _handle_mouse_release(self, mouse_pos: MousePos): if not self.is_visible: return # Check not in action rect if self.action_item: action_rect = self.get_right_item_rect(self._rect) if rl.check_collision_point_rec(mouse_pos, action_rect): # Click was on right item, don't toggle description return self._set_description_visible(not self.description_visible) super()._handle_mouse_release(mouse_pos) def _set_description_visible(self, visible: bool): if self.description and self.description_visible != visible: self.description_visible = visible # do callback first in case receiver changes description if self.description_visible and self.description_opened_callback is not None: self.description_opened_callback() # Call _update_state to catch any description changes self._update_state() content_width = int(self._rect.width - ITEM_PADDING * 2) self._rect.height = self.get_item_height(self._font, content_width) def _update_state(self): # Detect changes if description is callback new_description = self.description if new_description != self._prev_description: self._parse_description(new_description) def _render(self, _): if not self.is_visible: return # Don't draw items that are not in parent's viewport if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height): return content_x = self._rect.x + ITEM_PADDING text_x = content_x # Only draw title and icon for items that have them if self.title: # Draw icon if present if self.icon: rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) // 2), rl.WHITE) text_x += ICON_SIZE + ITEM_PADDING # Draw main text text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE) item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2 rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR) # Draw description if visible if self.description_visible: content_width = int(self._rect.width - ITEM_PADDING * 2) description_height = self._html_renderer.get_total_height(content_width) description_rect = rl.Rectangle(self._rect.x + ITEM_PADDING, self._rect.y + ITEM_DESC_V_OFFSET, content_width, description_height) self._html_renderer.render(description_rect) # Draw right item if present if self.action_item: right_rect = self.get_right_item_rect(self._rect) right_rect.y = self._rect.y if self.action_item.render(right_rect) and self.action_item.enabled: # Right item was clicked/activated if self._click_callback: self._click_callback() def set_icon(self, icon: str | None, starpilot: bool = False): self.icon = icon if not icon: self._icon_texture = None elif starpilot: self._icon_texture = gui_app.starpilot_texture(icon, ICON_SIZE, ICON_SIZE) elif self.icon: self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) else: self._icon_texture = None def set_description(self, description: str | Callable[[], str] | None): self._description = description def _parse_description(self, new_desc): self._html_renderer.parse_html_content(new_desc) self._prev_description = new_desc @property def title(self): return _resolve_value(self._title, "") @property def description(self): return _resolve_value(self._description, "") def get_item_height(self, font: rl.Font, max_width: int) -> float: if not self.is_visible: return 0 height = float(ITEM_BASE_HEIGHT) if self.description_visible: description_height = self._html_renderer.get_total_height(max_width) height += description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING return height def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: if not self.action_item: return rl.Rectangle(0, 0, 0, 0) right_width = self.action_item.get_width_hint() if right_width == 0: # Full width action (like DualButtonAction) return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT) # Return rect at right edge of item, with action's full width # The action itself will handle positioning within this rect (right-aligned) right_x = item_rect.x + item_rect.width - right_width - ITEM_PADDING right_y = item_rect.y return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) # Factory functions def simple_item(title: str | Callable[[], str], callback: Callable | None = None) -> ListItem: return ListItem(title=title, callback=callback) def toggle_item( title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, starpilot_icon: bool = False, ) -> ListItem: action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback) icon_to_use = "" if starpilot_icon else icon item = ListItem(title=title, description=description, action_item=action, icon=icon_to_use) if icon and starpilot_icon: item.set_icon(icon, starpilot=True) return item def button_item( title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, icon: str = "", starpilot_icon: bool = False, ) -> ListItem: action = ButtonAction(text=button_text, enabled=enabled) item = ListItem(title=title, description=description, action_item=action, callback=callback, icon=icon if not starpilot_icon else "") if icon and starpilot_icon: item.set_icon(icon, starpilot=True) return item def text_item( title: str | Callable[[], str], value: str | Callable[[], str], description: str | Callable[[], str] | None = None, callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, ) -> ListItem: action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, enabled=enabled) return ListItem(title=title, description=description, action_item=action, callback=callback) def dual_button_item( left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable = None, right_callback: Callable = None, description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True, ) -> ListItem: action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled) return ListItem(title="", description=description, action_item=action) def multiple_button_item( title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], selected_index: int, button_width: int = BUTTON_WIDTH, callback: Callable = None, icon: str = "", starpilot_icon: bool = False, ): action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback) item = ListItem(title=title, description=description, icon=icon if not starpilot_icon else "", action_item=action) if icon and starpilot_icon: item.set_icon(icon, starpilot=True) return item VALUE_BUTTON_WIDTH = 150 VALUE_BUTTON_HEIGHT = 80 VALUE_SLIDER_HEIGHT = 100 VALUE_DISPLAY_WIDTH = 250 VALUE_FONT_SIZE = 45 VALUE_TEXT_COLOR = rl.Color(224, 232, 121, 255) class ValueSliderAction(ItemAction): def __init__( self, value: float | Callable[[], float], min_val: float = 0, max_val: float = 100, step: float = 1, unit: str = "", labels: dict[float, str] | None = None, callback: Callable[[float], None] | None = None, enabled: bool | Callable[[], bool] = True, negative: bool = False, default_value: float | None = None, fast_increase: bool = False, is_metric: bool = False, ): self._params = Params() self._value_source = value self._min_val = min_val self._max_val = max_val self._step = step self._unit = unit self._labels = labels or {} self._callback = callback self._negative = negative self._default_value = default_value self._fast_increase = fast_increase self._is_metric = is_metric self._font = gui_app.font(FontWeight.MEDIUM) self._value_font = gui_app.font(FontWeight.DISPLAY) self._decrement_pressed = False self._increment_pressed = False self._repeat_timer = 0.0 self._repeat_delay = 0.5 self._repeat_interval = 0.1 self._params = Params() self._metric_multiplier = 1.0 if self._is_metric: if self._unit == "mph": self._metric_multiplier = 1.60934 elif self._unit == "feet": self._metric_multiplier = 0.3048 elif self._unit == "inches": self._metric_multiplier = 2.54 total_width = VALUE_DISPLAY_WIDTH + VALUE_BUTTON_WIDTH * 2 + 20 super().__init__(width=total_width, enabled=enabled) def _get_value(self) -> float: value = self._value_source if callable(value): return float(value()) if isinstance(value, (int, float)): return float(value) if isinstance(value, str) and value: param_key = value if self._step == 1: return float(self._params.get_int(param_key, return_default=True, default=0)) else: return float(self._params.get_float(param_key, return_default=True, default=0.0)) return 0.0 def _get_display_text(self, value: float) -> str: display_val = value display_unit = self._unit if self._is_metric and ui_state.is_metric and self._metric_multiplier != 1.0: display_val = value * self._metric_multiplier if self._unit == "mph": display_unit = "km/h" elif self._unit == "feet": display_unit = "m" elif self._unit == "inches": display_unit = "cm" rounded_value = round(display_val / self._step) * self._step if self._labels and rounded_value in self._labels: return self._labels[rounded_value] if display_unit: return f"{rounded_value:g}{display_unit}" return str(rounded_value) def _update_value(self, delta: float): current = self._get_value() if self._is_metric and ui_state.is_metric and self._metric_multiplier != 1.0: delta = delta / self._metric_multiplier min_val = _resolve_value(self._min_val, 0) max_val = _resolve_value(self._max_val, 100) new_value = max(min_val, min(max_val, current + delta)) new_value = round(new_value / self._step) * self._step if self._callback: self._callback(new_value) elif isinstance(self._value_source, str): param_key = self._value_source if param_key: if self._step == 1: self._params.put_int(param_key, int(new_value)) else: self._params.put_float(param_key, new_value) def _handle_decrement(self, dt: float): if self._decrement_pressed: self._repeat_timer += dt if self._repeat_timer >= self._repeat_delay: repeat_count = int((self._repeat_timer - self._repeat_delay) / self._repeat_interval) + 1 delta = -self._step if self._fast_increase: delta *= 5 for _ in range(repeat_count): self._update_value(delta) self._repeat_timer = self._repeat_timer % self._repeat_interval return True return False def _handle_increment(self, dt: float): if self._increment_pressed: self._repeat_timer += dt if self._repeat_timer >= self._repeat_delay: repeat_count = int((self._repeat_timer - self._repeat_delay) / self._repeat_interval) + 1 delta = self._step if self._fast_increase: delta *= 5 for _ in range(repeat_count): self._update_value(delta) self._repeat_timer = self._repeat_timer % self._repeat_interval return True return False def get_width_hint(self) -> float: return self._rect.width def _render(self, rect: rl.Rectangle) -> bool: value = self._get_value() display_text = self._get_display_text(value) button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 dec_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) inc_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) min_val = _resolve_value(self._min_val, 0) max_val = _resolve_value(self._max_val, 100) dec_color = rl.Color(57, 57, 57, 255) if value > min_val else rl.Color(40, 40, 40, 255) inc_color = rl.Color(57, 57, 57, 255) if value < max_val else rl.Color(40, 40, 40, 255) if self._decrement_pressed: dec_color = rl.Color(74, 74, 74, 255) if self._increment_pressed: inc_color = rl.Color(74, 74, 74, 255) if not self.enabled: dec_color = rl.Color(dec_color.r, dec_color.g, dec_color.b, 128) inc_color = rl.Color(inc_color.r, inc_color.g, inc_color.b, 128) rl.draw_rectangle_rounded(dec_btn_rect, 0.3, 10, dec_color) rl.draw_rectangle_rounded(inc_btn_rect, 0.3, 10, inc_color) dec_text = "-" inc_text = "+" dec_size = measure_text_cached(self._value_font, dec_text, 50) inc_size = measure_text_cached(self._value_font, inc_text, 50) rl.draw_text_ex( self._value_font, dec_text, rl.Vector2(dec_btn_rect.x + (dec_btn_rect.width - dec_size.x) / 2, dec_btn_rect.y + (dec_btn_rect.height - dec_size.y) / 2), 50, 0, rl.WHITE, ) rl.draw_text_ex( self._value_font, inc_text, rl.Vector2(inc_btn_rect.x + (inc_btn_rect.width - inc_size.x) / 2, inc_btn_rect.y + (inc_btn_rect.height - inc_size.y) / 2), 50, 0, rl.WHITE, ) value_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - VALUE_DISPLAY_WIDTH - 30, rect.y, VALUE_DISPLAY_WIDTH, rect.height) gui_label( value_rect, display_text, font_size=VALUE_FONT_SIZE, color=VALUE_TEXT_COLOR if self.enabled else rl.Color(100, 100, 100, 255), font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, ) return False def _handle_mouse_release(self, mouse_pos: MousePos): button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) value = self._get_value() min_val = _resolve_value(self._min_val, 0) max_val = _resolve_value(self._max_val, 100) # Only one button can be triggered - check all but use elif if rl.check_collision_point_rec(mouse_pos, inc_btn_rect) and self.enabled and value < max_val: self._update_value(self._step) elif rl.check_collision_point_rec(mouse_pos, dec_btn_rect) and self.enabled and value > min_val: self._update_value(-self._step) # Reset all pressed states self._decrement_pressed = False self._increment_pressed = False self._repeat_timer = 0.0 def _handle_mouse_event(self, mouse_event): # Don't call super - we handle everything here to avoid double-processing button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) # Visual feedback only - set pressed state if mouse_event.left_pressed: if rl.check_collision_point_rec(mouse_event.pos, inc_btn_rect) and self.enabled: self._increment_pressed = True elif rl.check_collision_point_rec(mouse_event.pos, dec_btn_rect) and self.enabled: self._decrement_pressed = True # Reset on release for visual feedback if mouse_event.left_released: self._decrement_pressed = False self._increment_pressed = False class ValueButtonSliderAction(ValueSliderAction): def __init__( self, value: float | Callable[[], float], min_val: float = 0, max_val: float = 100, step: float = 1, unit: str = "", button_text: str = "Reset", button_callback: Callable | None = None, labels: dict[float, str] | None = None, callback: Callable[[float], None] | None = None, enabled: bool | Callable[[], bool] = True, negative: bool = False, default_value: float | None = None, fast_increase: bool = False, sub_toggles: list[tuple[str, bool]] | None = None, is_metric: bool = False, ): super().__init__(value, min_val, max_val, step, unit, labels, callback, enabled, negative, default_value, fast_increase, is_metric) self._button_text = button_text self._button_callback = button_callback self._sub_toggles = sub_toggles or [] self._button_pressed = False button_width = 180 total_width = VALUE_DISPLAY_WIDTH + VALUE_BUTTON_WIDTH * 2 + button_width + 40 self._rect.width = total_width def _render(self, rect: rl.Rectangle) -> bool: value = self._get_value() display_text = self._get_display_text(value) button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 button_width = 180 dec_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - 40, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) inc_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH - button_width - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) action_btn_rect = rl.Rectangle( rect.x + rect.width - button_width, button_y + (VALUE_BUTTON_HEIGHT - VALUE_BUTTON_HEIGHT) / 2, button_width, VALUE_BUTTON_HEIGHT ) min_val = _resolve_value(self._min_val, 0) max_val = _resolve_value(self._max_val, 100) dec_color = rl.Color(57, 57, 57, 255) if value > min_val else rl.Color(40, 40, 40, 255) inc_color = rl.Color(57, 57, 57, 255) if value < max_val else rl.Color(40, 40, 40, 255) if self._decrement_pressed: dec_color = rl.Color(74, 74, 74, 255) if self._increment_pressed: inc_color = rl.Color(74, 74, 74, 255) if not self.enabled: dec_color = rl.Color(dec_color.r, dec_color.g, dec_color.b, 128) inc_color = rl.Color(inc_color.r, inc_color.g, inc_color.b, 128) rl.draw_rectangle_rounded(dec_btn_rect, 0.3, 10, dec_color) rl.draw_rectangle_rounded(inc_btn_rect, 0.3, 10, inc_color) dec_text = "-" inc_text = "+" dec_size = measure_text_cached(self._value_font, dec_text, 50) inc_size = measure_text_cached(self._value_font, inc_text, 50) rl.draw_text_ex( self._value_font, dec_text, rl.Vector2(dec_btn_rect.x + (dec_btn_rect.width - dec_size.x) / 2, dec_btn_rect.y + (dec_btn_rect.height - dec_size.y) / 2), 50, 0, rl.WHITE, ) rl.draw_text_ex( self._value_font, inc_text, rl.Vector2(inc_btn_rect.x + (inc_btn_rect.width - inc_size.x) / 2, inc_btn_rect.y + (inc_btn_rect.height - inc_size.y) / 2), 50, 0, rl.WHITE, ) value_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - VALUE_DISPLAY_WIDTH - 50, rect.y, VALUE_DISPLAY_WIDTH, rect.height) gui_label( value_rect, display_text, font_size=VALUE_FONT_SIZE, color=VALUE_TEXT_COLOR if self.enabled else rl.Color(100, 100, 100, 255), font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, ) btn_color = rl.Color(57, 57, 57, 255) if self._button_pressed: btn_color = rl.Color(74, 74, 74, 255) if not self.enabled: btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) rl.draw_rectangle_rounded(action_btn_rect, 0.3, 10, btn_color) btn_text = _resolve_value(self._button_text, "Reset") btn_font = gui_app.font(FontWeight.MEDIUM) btn_size = measure_text_cached(btn_font, btn_text, 35) rl.draw_text_ex( btn_font, btn_text, rl.Vector2(action_btn_rect.x + (action_btn_rect.width - btn_size.x) / 2, action_btn_rect.y + (action_btn_rect.height - btn_size.y) / 2), 35, 0, rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), ) return False def _handle_mouse_release(self, mouse_pos: MousePos): button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 button_width = 180 dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - 40, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH - button_width - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) action_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - button_width, button_y, button_width, VALUE_BUTTON_HEIGHT) value = self._get_value() min_val = _resolve_value(self._min_val, 0) max_val = _resolve_value(self._max_val, 100) # Only one button can be triggered - check all but use elif if rl.check_collision_point_rec(mouse_pos, action_btn_rect) and self.enabled: if self._button_callback: self._button_callback() elif rl.check_collision_point_rec(mouse_pos, inc_btn_rect) and self.enabled and value < max_val: self._update_value(self._step) elif rl.check_collision_point_rec(mouse_pos, dec_btn_rect) and self.enabled and value > min_val: self._update_value(-self._step) # Reset all pressed states self._decrement_pressed = False self._increment_pressed = False self._button_pressed = False self._repeat_timer = 0.0 def _handle_mouse_event(self, mouse_event): # Don't call super - we handle everything here to avoid double-processing button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 button_width = 180 dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - 40, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH - button_width - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) action_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - button_width, button_y, button_width, VALUE_BUTTON_HEIGHT) # Visual feedback only - set pressed state if mouse_event.left_pressed: if rl.check_collision_point_rec(mouse_event.pos, action_btn_rect) and self.enabled: self._button_pressed = True elif rl.check_collision_point_rec(mouse_event.pos, inc_btn_rect) and self.enabled: self._increment_pressed = True elif rl.check_collision_point_rec(mouse_event.pos, dec_btn_rect) and self.enabled: self._decrement_pressed = True # Reset on release for visual feedback if mouse_event.left_released: self._decrement_pressed = False self._increment_pressed = False self._button_pressed = False class DualValueSliderAction(ItemAction): def __init__( self, value1: float | Callable[[], float], value2: float | Callable[[], float], min_val: float = 0, max_val: float = 100, step: float = 1, unit: str = "", label1: str = "", label2: str = "", callback1: Callable[[float], None] | None = None, callback2: Callable[[float], None] | None = None, enabled: bool | Callable[[], bool] = True, labels: dict[float, str] | None = None, is_metric: bool = False, ): self._value1_source = value1 self._value2_source = value2 self._min_val = min_val self._max_val = max_val self._step = step self._unit = unit self._label1 = label1 self._label2 = label2 self._callback1 = callback1 self._callback2 = callback2 self._labels = labels or [] self._params = Params() self._metric_multiplier = 1.0 if is_metric: if self._unit == "mph": self._metric_multiplier = 1.60934 elif self._unit == "feet": self._metric_multiplier = 0.3048 elif self._unit == "inches": self._metric_multiplier = 2.54 self._slider1 = ValueSliderAction(value1, min_val, max_val, step, unit, labels, callback1, enabled, is_metric=is_metric) self._slider2 = ValueSliderAction(value2, min_val, max_val, step, unit, labels, callback2, enabled, is_metric=is_metric) total_width = (VALUE_DISPLAY_WIDTH + VALUE_BUTTON_WIDTH * 2 + 20) * 2 + 40 super().__init__(width=total_width, enabled=enabled) def _render(self, rect: rl.Rectangle) -> bool: half_width = (rect.width - 40) / 2 slider1_rect = rl.Rectangle(rect.x, rect.y, half_width, rect.height) slider2_rect = rl.Rectangle(rect.x + half_width + 40, rect.y, half_width, rect.height) if self._label1: label_rect = rl.Rectangle(slider1_rect.x, slider1_rect.y - 30, half_width, 25) gui_label(label_rect, self._label1, font_size=30, color=rl.Color(170, 170, 170, 255), font_weight=FontWeight.NORMAL) if self._label2: label_rect = rl.Rectangle(slider2_rect.x, slider2_rect.y - 30, half_width, 25) gui_label(label_rect, self._label2, font_size=30, color=rl.Color(170, 170, 170, 255), font_weight=FontWeight.NORMAL) return False def _handle_mouse_event(self, mouse_event): half_width = (self._rect.width - 40) / 2 slider1_rect = rl.Rectangle(self._rect.x, self._rect.y, half_width, self._rect.height) slider2_rect = rl.Rectangle(self._rect.x + half_width + 40, self._rect.y, half_width, self._rect.height) adjusted_pos1 = MousePos( rl.Vector2(mouse_event.pos.x - slider1_rect.x + self._rect.x, mouse_event.pos.y), mouse_event.left_pressed, mouse_event.left_released, mouse_event.right_pressed, mouse_event.right_released, ) adjusted_pos2 = MousePos( rl.Vector2(mouse_event.pos.x - slider2_rect.x + self._rect.x + half_width + 40, mouse_event.pos.y), mouse_event.left_pressed, mouse_event.left_released, mouse_event.right_pressed, mouse_event.right_released, ) if 0 <= mouse_event.pos.x - slider1_rect.x < half_width: self._slider1._handle_mouse_event(adjusted_pos1) if 0 <= mouse_event.pos.x - slider2_rect.x - half_width - 40 < half_width: self._slider2._handle_mouse_event(adjusted_pos2) class ButtonToggleAction(ItemAction): def __init__( self, state: bool | Callable[[], bool], sub_toggles: list[str] | None = None, sub_toggle_names: list[str] | None = None, callback: Callable[[bool], None] | None = None, sub_callbacks: list[Callable] | None = None, enabled: bool | Callable[[], bool] = True, exclusive: bool = False, ): self._state_source = state self._sub_toggles = sub_toggles or [] self._sub_toggle_names = sub_toggle_names or [] self._callback = callback self._sub_callbacks = sub_callbacks or [] self._exclusive = exclusive self._params = Params() self._toggle = Toggle(initial_state=_resolve_value(state, False), callback=callback) button_width = 180 total_width = TOGGLE_WIDTH + len(self._sub_toggles) * (button_width + 10) super().__init__(width=total_width, enabled=enabled) def get_width_hint(self) -> float: return self._rect.width def _render(self, rect: rl.Rectangle) -> bool: toggle_rect = rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, TOGGLE_WIDTH, TOGGLE_HEIGHT) self._toggle.set_enabled(self.enabled) clicked = self._toggle.render(toggle_rect) button_width = 180 button_spacing = 10 button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 x_offset = rect.x + TOGGLE_WIDTH + 20 for i, (sub_key, sub_name) in enumerate(zip(self._sub_toggles, self._sub_toggle_names)): btn_rect = rl.Rectangle(x_offset + i * (button_width + button_spacing), button_y, button_width, VALUE_BUTTON_HEIGHT) sub_value = False if isinstance(sub_key, str) and sub_key: sub_value = self._params.get_bool(sub_key) btn_color = rl.Color(51, 171, 76, 255) if sub_value else rl.Color(57, 57, 57, 255) if not self.enabled: btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) text_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), sub_name, 30) rl.draw_text_ex( gui_app.font(FontWeight.MEDIUM), sub_name, rl.Vector2(btn_rect.x + (btn_rect.width - text_size.x) / 2, btn_rect.y + (btn_rect.height - text_size.y) / 2), 30, 0, rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), ) return clicked def _handle_mouse_release(self, mouse_pos: MousePos): button_width = 180 button_spacing = 10 button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 x_offset = self._rect.x + TOGGLE_WIDTH + 20 for i, (sub_key, sub_callback) in enumerate(zip(self._sub_toggles, self._sub_callbacks)): btn_rect = rl.Rectangle(x_offset + i * (button_width + button_spacing), button_y, button_width, VALUE_BUTTON_HEIGHT) if rl.check_collision_point_rec(mouse_pos, btn_rect) and self.enabled: if isinstance(sub_key, str) and sub_key: current = self._params.get_bool(sub_key) self._params.put_bool(sub_key, not current) if sub_callback: sub_callback() class MultiButtonsAction(ItemAction): def __init__( self, buttons: list[str | Callable[[], str]], button_callbacks: list[Callable] | None = None, enabled: bool | Callable[[], bool] = True, initial_value: str = "", ): self._buttons_source = buttons self._button_callbacks = button_callbacks or [] self._initial_value = initial_value button_width = 180 total_width = len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING super().__init__(width=total_width, enabled=enabled) self._font = gui_app.font(FontWeight.MEDIUM) def get_width_hint(self) -> float: return self._rect.width def _render(self, rect: rl.Rectangle) -> bool: buttons = _resolve_value(self._buttons_source, []) button_width = 180 spacing = RIGHT_ITEM_PADDING button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 pressed_any = False for i, btn_text in enumerate(buttons): btn_x = rect.x + i * (button_width + spacing) btn_rect = rl.Rectangle(btn_x, button_y, button_width, VALUE_BUTTON_HEIGHT) btn_color = rl.Color(57, 57, 57, 255) if not self.enabled: btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) text = _resolve_value(btn_text, "") text_size = measure_text_cached(self._font, text, 30) text_x = btn_rect.x + (btn_rect.width - text_size.x) / 2 text_y = btn_rect.y + (btn_rect.height - text_size.y) / 2 rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 30, 0, rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255)) return False def _handle_mouse_release(self, mouse_pos: MousePos): buttons = _resolve_value(self._buttons_source, []) button_width = 180 spacing = RIGHT_ITEM_PADDING button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 for i, callback in enumerate(self._button_callbacks): btn_rect = rl.Rectangle(self._rect.x + i * (button_width + spacing), button_y, button_width, VALUE_BUTTON_HEIGHT) if rl.check_collision_point_rec(mouse_pos, btn_rect) and self.enabled: if callback: callback() class SelectionButtonAction(ItemAction): def __init__( self, options: list[str], selected_index: int = 0, callback: Callable[[int, str], None] | None = None, enabled: bool | Callable[[], bool] = True, ): self._options = options self._selected_index = selected_index self._callback = callback self._params = Params() self._button_pressed = False button_width = 300 super().__init__(width=button_width, enabled=enabled) self._font = gui_app.font(FontWeight.MEDIUM) def get_width_hint(self) -> float: return self._rect.width def _render(self, rect: rl.Rectangle) -> bool: btn_rect = rl.Rectangle(rect.x, rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2, self._rect.width, VALUE_BUTTON_HEIGHT) btn_color = rl.Color(57, 57, 57, 255) if self._button_pressed: btn_color = rl.Color(74, 74, 74, 255) if not self.enabled: btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) current_option = self._options[self._selected_index] if 0 <= self._selected_index < len(self._options) else "" text_size = measure_text_cached(self._font, current_option, 35) rl.draw_text_ex( self._font, current_option, rl.Vector2(btn_rect.x + 20, btn_rect.y + (btn_rect.height - text_size.y) / 2), 35, 0, rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), ) arrow_text = "▼" arrow_size = measure_text_cached(self._font, arrow_text, 25) rl.draw_text_ex( self._font, arrow_text, rl.Vector2(btn_rect.x + btn_rect.width - arrow_size.x - 20, btn_rect.y + (btn_rect.height - arrow_size.y) / 2), 25, 0, rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), ) return self._button_pressed def _handle_mouse_release(self, mouse_pos: MousePos): btn_rect = rl.Rectangle(self._rect.x, self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2, self._rect.width, VALUE_BUTTON_HEIGHT) if rl.check_collision_point_rec(mouse_pos, btn_rect) and self.enabled and self._button_pressed: if self._callback: self._callback(self._selected_index, self._options[self._selected_index] if 0 <= self._selected_index < len(self._options) else "") self._button_pressed = False def _handle_mouse_event(self, mouse_event): super()._handle_mouse_event(mouse_event) btn_rect = rl.Rectangle(self._rect.x, self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2, self._rect.width, VALUE_BUTTON_HEIGHT) if mouse_event.left_pressed: if rl.check_collision_point_rec(mouse_event.pos, btn_rect) and self.enabled: self._button_pressed = True if mouse_event.left_released: if not rl.check_collision_point_rec(mouse_event.pos, btn_rect): self._button_pressed = False class LabelAction(ItemAction): def __init__( self, value: str | Callable[[], str], enabled: bool | Callable[[], bool] = True, ): self._value_source = value self._font = gui_app.font(FontWeight.NORMAL) initial_text = _resolve_value(value, "") text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x super().__init__(width=int(text_width + TEXT_PADDING), enabled=enabled) def get_width_hint(self) -> float: text = _resolve_value(self._value_source, "") text_width = measure_text_cached(self._font, text, ITEM_TEXT_FONT_SIZE).x return text_width + TEXT_PADDING def _render(self, rect: rl.Rectangle) -> bool: value = _resolve_value(self._value_source, "") gui_label( rect, value, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR if self.enabled else rl.Color(100, 100, 100, 255), font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, ) return False def value_item( title: str | Callable[[], str], value: float | Callable[[], float], min_val: float = 0, max_val: float = 100, step: float = 1, unit: str = "", description: str | Callable[[], str] | None = None, callback: Callable[[float], None] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, labels: dict[float, str] | None = None, negative: bool = False, default_value: float | None = None, fast_increase: bool = False, is_metric: bool = False, ) -> ListItem: action = ValueSliderAction(value, min_val, max_val, step, unit, labels, callback, enabled, negative, default_value, fast_increase, is_metric) return ListItem(title=title, description=description, icon=icon, action_item=action) def value_button_item( title: str | Callable[[], str], value: float | Callable[[], float], min_val: float = 0, max_val: float = 100, step: float = 1, unit: str = "", button_text: str = "Reset", button_callback: Callable | None = None, description: str | Callable[[], str] | None = None, callback: Callable[[float], None] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, sub_toggles: list[tuple[str, bool]] | None = None, labels: dict[float, str] | None = None, negative: bool = False, default_value: float | None = None, fast_increase: bool = False, is_metric: bool = False, ) -> ListItem: action = ValueButtonSliderAction( value, min_val, max_val, step, unit, button_text, button_callback, labels, callback, enabled, negative, default_value, fast_increase, sub_toggles, is_metric ) return ListItem(title=title, description=description, icon=icon, action_item=action) def dual_value_item( title: str | Callable[[], str], value1: float | Callable[[], float], value2: float | Callable[[], float], min_val: float = 0, max_val: float = 100, step: float = 1, unit: str = "", label1: str = "", label2: str = "", description: str | Callable[[], str] | None = None, callback1: Callable[[float], None] | None = None, callback2: Callable[[float], None] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, labels: dict[float, str] | None = None, is_metric: bool = False, ) -> ListItem: action = DualValueSliderAction(value1, value2, min_val, max_val, step, unit, label1, label2, callback1, callback2, enabled, labels, is_metric) return ListItem(title=title, description=description, icon=icon, action_item=action) def button_toggle_item( title: str | Callable[[], str], state: bool | Callable[[], bool], sub_toggles: list[str] | None = None, sub_toggle_names: list[str] | None = None, description: str | Callable[[], str] | None = None, callback: Callable[[bool], None] | None = None, sub_callbacks: list[Callable] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, exclusive: bool = False, ) -> ListItem: action = ButtonToggleAction(state, sub_toggles, sub_toggle_names, callback, sub_callbacks, enabled, exclusive) return ListItem(title=title, description=description, icon=icon, action_item=action) def buttons_item( title: str | Callable[[], str], buttons: list[str], button_callbacks: list[Callable] | None = None, description: str | Callable[[], str] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, initial_value: str = "", ) -> ListItem: action = MultiButtonsAction(buttons, button_callbacks, enabled, initial_value) return ListItem(title=title, description=description, icon=icon, action_item=action) def selection_button_item( title: str | Callable[[], str], options: list[str], selected_index: int = 0, description: str | Callable[[], str] | None = None, callback: Callable[[int, str], None] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, ) -> ListItem: action = SelectionButtonAction(options, selected_index, callback, enabled) return ListItem(title=title, description=description, icon=icon, action_item=action) def label_item( title: str | Callable[[], str], value: str | Callable[[], str], description: str | Callable[[], str] | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, ) -> ListItem: action = LabelAction(value, enabled) return ListItem(title=title, description=description, icon=icon, action_item=action)