mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-02-18 20:03:53 +08:00
* [TIZI/TICI] ui: individual button states support for MultipleButtonActionSP * no magic nums
412 lines
19 KiB
Python
412 lines
19 KiB
Python
"""
|
|
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
|
|
|
This file is part of sunnypilot and is licensed under the MIT License.
|
|
See the LICENSE.md file in the root directory for more details.
|
|
"""
|
|
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.sunnypilot.widgets.toggle import ToggleSP
|
|
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.sunnypilot.lib.styles import style
|
|
from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP, 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 ToggleActionSP(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 = ToggleSP(initial_state=initial_state, callback=callback, param=param)
|
|
|
|
|
|
class ButtonSP(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 SimpleButtonActionSP(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 = ButtonSP(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 ButtonActionSP(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 DualButtonActionSP(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 MultipleButtonActionSP(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.enabled_buttons: set[int] | None = None
|
|
|
|
def set_enabled_buttons(self, indices: set[int] | None):
|
|
self.enabled_buttons = indices
|
|
|
|
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
|
|
text_color = style.ITEM_TEXT_COLOR if self.enabled else style.MBC_DISABLED
|
|
highlight_color = style.MBC_BG_CHECKED_ENABLED if self.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
|
|
|
|
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
|
|
|
|
# Check individual button enabled state
|
|
is_button_enabled = self.enabled and (self.enabled_buttons is None or i in self.enabled_buttons)
|
|
current_text_color = text_color if is_button_enabled else style.MBC_DISABLED
|
|
|
|
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, current_text_color)
|
|
|
|
def _handle_mouse_release(self, mouse_pos: MousePos):
|
|
# Override parent method to check individual button enabled state
|
|
if not self.enabled:
|
|
return
|
|
|
|
button_y = self._rect.y + (self._rect.height - style.BUTTON_HEIGHT) / 2
|
|
for i, _ in enumerate(self.buttons):
|
|
button_x = self._rect.x + i * self.button_width
|
|
button_rect = rl.Rectangle(button_x, button_y, self.button_width, style.BUTTON_HEIGHT)
|
|
|
|
if rl.check_collision_point_rec(mouse_pos, button_rect):
|
|
# Check if this specific button is enabled
|
|
if self.enabled_buttons is not None and i not in self.enabled_buttons:
|
|
return
|
|
|
|
self.selected_button = i
|
|
if self.callback:
|
|
self.callback(i)
|
|
|
|
if self.param_key:
|
|
self.params.put(self.param_key, self.selected_button)
|
|
|
|
|
|
class ListItemSP(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 set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
|
|
super().set_parent_rect(parent_rect)
|
|
if self.description_visible:
|
|
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, SimpleButtonActionSP):
|
|
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, SimpleButtonActionSP)
|
|
|
|
if left_action_item:
|
|
item_height = style.SIMPLE_BUTTON_HEIGHT if isinstance(self.action_item, SimpleButtonActionSP) 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_sp(button_text: str | Callable[[], str], callback: Callable | None = None,
|
|
enabled: bool | Callable[[], bool] = True, button_width: int = style.SIMPLE_BUTTON_WIDTH) -> ListItemSP:
|
|
action = SimpleButtonActionSP(button_text=button_text, enabled=enabled, callback=callback, button_width=button_width)
|
|
return ListItemSP(title="", callback=callback, description="", action_item=action)
|
|
|
|
|
|
def toggle_item_sp(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) -> ListItemSP:
|
|
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
|
|
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
|
|
|
|
|
|
def multiple_button_item_sp(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) -> ListItemSP:
|
|
action = MultipleButtonActionSP(buttons, button_width, selected_index, callback=callback, param=param)
|
|
return ListItemSP(title=title, description=description, icon=icon, action_item=action, inline=inline)
|
|
|
|
|
|
def option_item_sp(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) -> ListItemSP:
|
|
action = OptionControlSP(
|
|
param, min_value, max_value, value_change_step,
|
|
enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback
|
|
)
|
|
return ListItemSP(title=title, description=description, action_item=action, icon=icon, inline=inline)
|
|
|
|
|
|
def button_item_sp(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
|
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItemSP:
|
|
action = ButtonActionSP(text=button_text, enabled=enabled)
|
|
return ListItemSP(title=title, description=description, action_item=action, callback=callback)
|
|
|
|
|
|
def dual_button_item_sp(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) -> ListItemSP:
|
|
action = DualButtonActionSP(left_text, right_text, left_callback, right_callback, enabled, border_radius)
|
|
return ListItemSP(title="", description=description, action_item=action)
|
|
|
|
|
|
class LineSeparatorSP(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)
|