Files
2026-06-06 13:28:56 -05:00

423 lines
19 KiB
Python

"""
Copyright (c) 2021-, IQ.Pilot contributors.
This file is part of IQ.Pilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import time
from collections.abc import Callable
import pyray as rl
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.iqpilot.widgets.toggle import IQToggle
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.list_view import ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, \
_resolve_value, BUTTON_WIDTH, BUTTON_HEIGHT, TEXT_PADDING, DualButtonAction
from openpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING
from openpilot.system.ui.iqpilot.lib.styles import style
from openpilot.system.ui.iqpilot.widgets.option_control import IQOptionControl, LABEL_WIDTH
class Spacer(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.x + self._rect.width), int(self._rect.y), rl.Color(0,0,0,0))
class IQToggleAction(ToggleAction):
def __init__(self, initial_state: bool = False, width: int = style.TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
callback: Callable[[bool], None] | None = None, param: str | None = None):
ToggleAction.__init__(self, initial_state, width, enabled, callback)
self.toggle = IQToggle(initial_state=initial_state, callback=callback, param=param)
class IQButton(Button):
def _update_state(self):
super()._update_state()
if self.enabled:
if self.is_pressed:
self._background_color = style.BUTTON_OFF_PRESSED
else:
self._background_color = style.BUTTON_ENABLED_OFF
else:
self._background_color = style.BUTTON_DISABLED
self._label.set_text_color(style.BUTTON_TEXT_DISABLED)
class IQSimpleButtonAction(ItemAction):
def __init__(self, button_text: str | Callable[[], str], callback: Callable | None = None,
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH):
super().__init__(width=button_width, enabled=enabled)
self.button_action = IQButton(button_text, click_callback=callback, button_style=ButtonStyle.NORMAL,
border_radius=20)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.button_action.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle) -> bool | int | None:
self.button_action.set_enabled(self.enabled)
return self.button_action.render(rect)
class IQButtonAction(ButtonAction):
def __init__(self, text: str | Callable[[], str], width: int = style.BUTTON_ACTION_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(text=text, width=width, enabled=enabled)
self._value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
def set_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
self._value_source = value
self._value_color = color
def _render(self, rect: rl.Rectangle) -> bool:
"""Duplicate of ButtonAction._render, with additional value rendering"""
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=style.ITEM_TEXT_FONT_SIZE, color=self._value_color,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
pressed = self._pressed
self._pressed = False
return pressed
class IQDualButtonAction(DualButtonAction):
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None,
right_callback: Callable | None = None, enabled: bool | Callable[[], bool] = True, border_radius: int = 15):
DualButtonAction.__init__(self, left_text, right_text, left_callback, right_callback, enabled)
self.left_button._border_radius = self.right_button._border_radius = border_radius
def _render(self, rect: rl.Rectangle):
button_spacing = 20
button_height = 150
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 IQMultipleButtonAction(MultipleButtonAction):
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None,
param: str | None = None):
MultipleButtonAction.__init__(self, buttons, button_width, selected_index, callback)
self.param_key = param
self.params = Params()
if self.param_key:
self.selected_button = int(self.params.get(self.param_key, return_default=True))
self._anim_x: float | None = None
self._double_click_callbacks: dict[int, Callable] = {}
self._last_click_button: int = -1
self._last_click_time: float = 0.0
def set_double_click_callback(self, button_index: int, callback: Callable) -> None:
self._double_click_callbacks[button_index] = callback
def _render(self, rect: rl.Rectangle):
button_y = rect.y + (rect.height - style.BUTTON_HEIGHT) / 2
total_width = len(self.buttons) * self.button_width
track_rect = rl.Rectangle(rect.x, button_y, total_width, style.BUTTON_HEIGHT)
bg_color = style.MBC_TRANSPARENT
selected_enabled = self._is_button_enabled(self.selected_button)
highlight_color = style.MBC_BG_CHECKED_ENABLED if selected_enabled else style.MBC_DISABLED
# background
rl.draw_rectangle_rounded(track_rect, 0.2, 20, bg_color)
# border
border_color = style.MBC_BG_CHECKED_ENABLED if self.enabled else style.MBC_DISABLED
rl.draw_rectangle_rounded_lines_ex(track_rect, 0.2, 20, 2, border_color)
# highlight with animation
target_x = rect.x + self.selected_button * self.button_width
if not self._anim_x:
self._anim_x = target_x
self._anim_x += (target_x - self._anim_x) * 0.2
highlight_rect = rl.Rectangle(self._anim_x, button_y, self.button_width, style.BUTTON_HEIGHT)
rl.draw_rectangle_rounded(highlight_rect, 0.2, 20, highlight_color)
# text
for i, _text in enumerate(self.buttons):
button_x = rect.x + i * self.button_width
button_enabled = self._is_button_enabled(i)
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 + (style.BUTTON_HEIGHT - text_size.y) / 2
text_color = style.ITEM_TEXT_COLOR if button_enabled else style.MBC_DISABLED
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):
MultipleButtonAction._handle_mouse_release(self, mouse_pos)
if self.param_key:
self.params.put(self.param_key, self.selected_button)
if not self._double_click_callbacks:
return
button_y = self._rect.y + (self._rect.height - style.BUTTON_HEIGHT) / 2
clicked_button = -1
for i in range(len(self.buttons)):
button_rect = rl.Rectangle(self._rect.x + i * self.button_width, button_y, self.button_width, style.BUTTON_HEIGHT)
if rl.check_collision_point_rec(mouse_pos, button_rect):
clicked_button = i
break
if clicked_button < 0:
return
now = time.monotonic()
if clicked_button == self._last_click_button and (now - self._last_click_time) < 0.5:
if clicked_button in self._double_click_callbacks:
self._double_click_callbacks[clicked_button]()
self._last_click_button = -1
self._last_click_time = 0.0
else:
self._last_click_button = clicked_button
self._last_click_time = now
class IQListItem(ListItem):
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, inline: bool = True, title_color: rl.Color = style.ITEM_TEXT_COLOR):
ListItem.__init__(self, title, icon, description, description_visible, callback, action_item)
self.title_color = title_color
self.inline = inline
if not self.inline:
self._rect.height += style.ITEM_BASE_HEIGHT/1.75
self._right_value_source: str | Callable[[], str] | None = None
self._right_value_font = gui_app.font(FontWeight.NORMAL)
self._right_value_color: rl.Color = style.ITEM_TEXT_VALUE_COLOR
def set_title(self, title: str | Callable[[], str] = ""):
self._title = title
def set_right_value(self, value: str | Callable[[], str], color: rl.Color = style.ITEM_TEXT_VALUE_COLOR):
self._right_value_source = value
self._right_value_color = color
@property
def right_value(self) -> str:
if self._right_value_source is None:
return ""
return str(_resolve_value(self._right_value_source, ""))
def _update_state(self):
prev_desc = self._prev_description
super()._update_state()
if self.description_visible and self._prev_description != prev_desc:
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
self._rect.height = self.get_item_height(self._font, content_width)
def get_item_height(self, font: rl.Font, max_width: int) -> float:
height = super().get_item_height(font, max_width)
if self.description_visible:
height += style.ITEM_PADDING * 1.5
if not self.inline:
height += style.ITEM_BASE_HEIGHT / 1.75
return height
def show_description(self, show: bool):
self._set_description_visible(show)
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
if not self.inline:
text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
action_y = item_rect.y + text_size.y + style.ITEM_PADDING * 3
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, action_y, item_rect.width - (style.ITEM_PADDING * 2), style.BUTTON_HEIGHT)
right_width = self.action_item.get_width_hint()
if right_width == 0:
return rl.Rectangle(item_rect.x + style.ITEM_PADDING, item_rect.y, item_rect.width - (style.ITEM_PADDING * 2), style.ITEM_BASE_HEIGHT)
content_width = item_rect.width - (style.ITEM_PADDING * 2)
title_width = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE).x
right_width = min(content_width - title_width, right_width)
if isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, IQSimpleButtonAction):
action_x = item_rect.x
else:
action_x = item_rect.x + item_rect.width - right_width
action_y = item_rect.y
return rl.Rectangle(action_x, action_y, right_width, style.ITEM_BASE_HEIGHT)
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 + style.ITEM_PADDING
text_x = content_x
left_action_item = isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, IQSimpleButtonAction)
if left_action_item:
item_height = style.SIMPLE_BUTTON_HEIGHT if isinstance(self.action_item, IQSimpleButtonAction) else style.TOGGLE_HEIGHT
left_rect = rl.Rectangle(
content_x,
self._rect.y + (style.ITEM_BASE_HEIGHT - item_height) // 2,
self.action_item.rect.width,
item_height
)
text_x = left_rect.x + left_rect.width + style.ITEM_PADDING * 1.5
# Draw title
if self.title:
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
value_text = self.right_value
if value_text:
# area from after the title to the right edge of the row
value_rect = rl.Rectangle(
text_x, # start at the beginning of the text area
self._rect.y,
self._rect.width - (text_x - self._rect.x) - style.ITEM_PADDING,
style.ITEM_BASE_HEIGHT,
)
if value_rect.width > 0:
gui_label(value_rect, value_text, font_size=style.ITEM_TEXT_FONT_SIZE, color=self._right_value_color, font_weight=FontWeight.NORMAL,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# Render toggle and handle callback
if self.action_item.render(left_rect) and self.action_item.enabled:
if self.callback:
self.callback()
else:
if self.title:
# Draw main text
self._text_size = measure_text_cached(self._font, self.title, style.ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (style.ITEM_BASE_HEIGHT - self._text_size.y) // 2 if self.inline else self._rect.y + style.ITEM_PADDING * 1.5
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), style.ITEM_TEXT_FONT_SIZE, 0, self.title_color)
# Draw right item if present
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if self.callback:
self.callback()
# Draw description if visible
if self.description_visible:
content_width = int(self._rect.width - style.ITEM_PADDING * 2)
description_height = self._html_renderer.get_total_height(content_width)
desc_y = self._rect.y + style.ITEM_DESC_V_OFFSET
if not self.inline and self.action_item:
desc_y = self.action_item.rect.y + style.ITEM_DESC_V_OFFSET - style.ITEM_PADDING * 0.5
description_rect = rl.Rectangle(self._rect.x + style.ITEM_PADDING, desc_y, content_width, description_height)
self._html_renderer.render(description_rect)
def simple_button_item_iq(button_text: str | Callable[[], str], callback: Callable | None = None,
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH) -> IQListItem:
action = IQSimpleButtonAction(button_text=button_text, enabled=enabled, callback=callback, button_width=button_width)
return IQListItem(title="", callback=callback, description="", action_item=action)
def toggle_item_iq(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, param: str | None = None) -> IQListItem:
action = IQToggleAction(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
return IQListItem(title=title, description=description, action_item=action, icon=icon, callback=callback)
def multiple_button_item_iq(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
selected_index: int = 0, button_width: int = style.BUTTON_ACTION_WIDTH, callback: Callable | None = None,
icon: str = "", param: str | None = None, inline: bool = False) -> IQListItem:
action = IQMultipleButtonAction(buttons, button_width, selected_index, callback=callback, param=param)
return IQListItem(title=title, description=description, icon=icon, action_item=action, inline=inline)
def option_item_iq(title: str | Callable[[], str], param: str,
min_value: int, max_value: int, description: str | Callable[[], str] | None = None,
value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None,
enabled: bool | Callable[[], bool] = True,
icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None,
use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None, inline: bool = False) -> IQListItem:
action = IQOptionControl(
param, min_value, max_value, value_change_step,
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
)
return IQListItem(title=title, description=description, action_item=action, icon=icon, inline=inline)
def button_item_iq(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> IQListItem:
action = IQButtonAction(text=button_text, enabled=enabled)
return IQListItem(title=title, description=description, action_item=action, callback=callback)
def dual_button_item_iq(left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None,
right_callback: Callable | None = None, description: str | Callable[[], str] | None = None,
enabled: bool | Callable[[], bool] = True, border_radius: int = 15) -> IQListItem:
action = IQDualButtonAction(left_text, right_text, left_callback, right_callback, enabled, border_radius)
return IQListItem(title="", description=description, action_item=action)
# Preferred IQ helper names.
simple_button_item = simple_button_item_iq
toggle_item = toggle_item_iq
multiple_button_item = multiple_button_item_iq
option_item = option_item_iq
button_item = button_item_iq
dual_button_item = dual_button_item_iq
class IQLineSeparator(LineSeparator):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def _render(self, _):
line_y = int(self._rect.y + self._rect.height // 2)
rl.draw_line(int(self._rect.x) + LINE_PADDING, line_y,
int(self._rect.x + self._rect.width) - LINE_PADDING, line_y,
LINE_COLOR)