This commit is contained in:
firestar5683
2026-04-11 21:56:46 -05:00
parent d2e5f06395
commit d43b7d0d3f
187 changed files with 5499 additions and 6222 deletions
+172 -140
View File
@@ -1,11 +1,11 @@
import math
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.label import UnifiedLabel
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
@@ -16,8 +16,7 @@ except ImportError:
SCROLLING_SPEED_PX_S = 50
COMPLICATION_SIZE = 36
LABEL_COLOR = rl.WHITE
LABEL_HORIZONTAL_PADDING = 40
LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
@@ -29,50 +28,51 @@ class ScrollState(Enum):
class BigCircleButton(Widget):
def __init__(self, icon: str, red: bool = False):
def __init__(self, icon: rl.Texture, red: bool = False, 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)
self._click_delay = 0.075
# Icons
self._txt_icon = gui_app.texture(icon, 64, 53)
self._txt_icon = icon
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_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_pressed.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)
self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180)
def set_enable_pressed_state(self, pressed: bool):
self._press_state_enabled = pressed
def _draw_content(self, btn_y: float):
# draw icon
icon_color = rl.Color(255, 255, 255, int(255 * 0.9)) if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
rl.draw_texture_ex(self._txt_icon, (self._rect.x + (self._rect.width - self._txt_icon.width) / 2 + self._icon_offset[0],
btn_y + (self._rect.height - self._txt_icon.height) / 2 + self._icon_offset[1]), 0, 1.0, icon_color)
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:
elif self.is_pressed:
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)
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)
# 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),
int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2), icon_color)
self._draw_content(btn_y)
class BigCircleToggle(BigCircleButton):
def __init__(self, icon: str, toggle_callback: Callable = None):
super().__init__(icon, False)
def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, False, icon_offset=icon_offset)
self._toggle_callback = toggle_callback
# State
@@ -80,7 +80,7 @@ class BigCircleToggle(BigCircleButton):
# 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", 70, 70) # TODO: why discrepancy?
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
@@ -92,62 +92,47 @@ class BigCircleToggle(BigCircleButton):
if self._toggle_callback:
self._toggle_callback(self._checked)
def _render(self, _):
super()._render(_)
def _draw_content(self, btn_y: float):
super()._draw_content(btn_y)
# 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)
rl.draw_texture_ex(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2, btn_y + 5),
0, 1.0, rl.WHITE)
class BigButton(Widget):
LABEL_HORIZONTAL_PADDING = 40
LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = ""):
def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 402, 180))
self.text = text
self.value = value
self.set_icon(icon)
self._label_font_size_override: int | None = None
self._txt_icon = icon
self._scroll = scroll
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._click_delay = 0.075
self._shake_start: float | None = None
self._grow_animation_until: float | None = None
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._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD,
text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll,
line_height=0.9)
self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN,
text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._update_label_layout()
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, 64, 64) if isinstance(icon, str) and len(icon) else icon
def _refresh_label_metrics(self):
font_size = self._label_font_size_override if self._label_font_size_override is not None else self._get_label_font_size()
self._label.set_font_size(font_size)
self._needs_scroll = measure_text_cached(self._label_font, self.text, font_size).x + 25 > self._rect.width
self._scroll_offset = 0
self._scroll_timer = 0
self._scroll_state = ScrollState.PRE_SCROLL
def _set_label_font_size_override(self, font_size: int | None):
self._label_font_size_override = font_size
self._refresh_label_metrics()
def set_icon(self, icon: Union[rl.Texture, None]):
self._txt_icon = icon
def set_rotate_icon(self, rotate: bool):
if rotate and self._rotate_icon_t is not None:
@@ -158,32 +143,37 @@ class BigButton(Widget):
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 set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None)
def _width_hint(self) -> int:
# Single line if scrolling, so hide behind icon if exists
icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
def _get_label_font_size(self):
if len(self.text) < 12:
font_size = 64
elif len(self.text) < 17:
font_size = 48
elif len(self.text) < 20:
font_size = 42
if len(self.text) <= 18:
return 48
else:
font_size = 36
return 42
def _update_label_layout(self):
self._label.set_font_size(self._get_label_font_size())
if self.value:
font_size -= 20
return font_size
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
else:
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
def set_text(self, text: str):
self.text = text
self._label.set_text(text)
self._refresh_label_metrics()
self._update_label_layout()
def set_value(self, value: str):
self.value = value
self._sub_label.set_text(value)
self._refresh_label_metrics()
self._update_label_layout()
def get_value(self) -> str:
return self.value
@@ -191,64 +181,60 @@ class BigButton(Widget):
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()
def trigger_shake(self):
self._shake_start = rl.get_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
def trigger_grow_animation(self, duration: float = 0.65):
self._grow_animation_until = rl.get_time() + duration
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
@property
def _shake_offset(self) -> float:
SHAKE_DURATION = 0.5
SHAKE_AMPLITUDE = 24.0
SHAKE_FREQUENCY = 32.0
if self._shake_start is None:
return 0.0
t = rl.get_time() - self._shake_start
if t > SHAKE_DURATION:
return 0.0
decay = 1.0 - t / SHAKE_DURATION
return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY)
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 set_position(self, x: float, y: float) -> None:
super().set_position(x + self._shake_offset, y)
def _handle_background(self) -> tuple[rl.Texture, float, float, float]:
if self._grow_animation_until is not None:
if rl.get_time() >= self._grow_animation_until:
self._grow_animation_until = None
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
txt_bg = self._txt_pressed_bg
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed or self._grow_animation_until is not None 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)
return txt_bg, btn_x, btn_y, scale
def _draw_content(self, btn_y: float):
# 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_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING
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()
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
self._sub_label.render(sub_label_rect)
# ICON -------------------------------------------------------------------
if self._txt_icon:
@@ -256,23 +242,35 @@ class BigButton(Widget):
if self._rotate_icon_t is not None:
rotation = (rl.get_time() - self._rotate_icon_t) * 180
# drop top right with 30px padding
# draw 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
y = btn_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)
dest_rec = rl.Rectangle(x, 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)
rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.Color(255, 255, 255, int(255 * 0.9)))
def _render(self, _):
txt_bg, btn_x, btn_y, scale = self._handle_background()
if self._scroll:
# draw black background since images are transparent
scaled_rect = rl.Rectangle(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
rl.draw_rectangle_rounded(scaled_rect, 0.4, 7, rl.Color(0, 0, 0, int(255 * 0.5)))
self._draw_content(btn_y)
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
else:
rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
self._draw_content(btn_y)
class BigToggle(BigButton):
def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable = None):
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._set_label_font_size_override(48)
def _load_images(self):
super()._load_images()
self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
@@ -290,35 +288,30 @@ class BigToggle(BigButton):
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)
rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE)
else:
rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE)
rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE)
def _render(self, _):
super()._render(_)
def _draw_content(self, btn_y: float):
super()._draw_content(btn_y)
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
y = self._rect.y
y = btn_y
self._draw_pill(x, y, self._checked)
class BigMultiToggle(BigToggle):
def __init__(self, text: str, options: list[str], toggle_callback: Callable = None,
select_callback: Callable = None):
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))
# Keep the title size stable when the selected option changes.
self._set_label_font_size_override(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 _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
@@ -328,22 +321,60 @@ class BigMultiToggle(BigToggle):
if self._select_callback:
self._select_callback(self.value)
def _render(self, _):
BigButton._render(self, _)
def _draw_content(self, btn_y: float):
# don't draw pill from BigToggle
BigButton._draw_content(self, btn_y)
checked_idx = self._options.index(self.value)
x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
y = self._rect.y
y = btn_y
for i in range(len(self._options)):
self._draw_pill(x, y, checked_idx == i)
y += 35
class GreyBigButton(BigButton):
"""Users should manage newlines with this class themselves"""
LABEL_HORIZONTAL_PADDING = 30
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_touch_valid_callback(lambda: False)
self._rect.width = 476
self._label.set_font_size(36)
self._label.set_font_weight(FontWeight.BOLD)
self._label.set_line_height(1.0)
self._sub_label.set_font_size(36)
self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR)
self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._sub_label.set_line_height(0.95)
@property
def LABEL_VERTICAL_PADDING(self):
return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _get_label_font_size(self):
return 36
def _render(self, _):
rl.draw_rectangle_rounded(self._rect, 0.4, 10, rl.Color(255, 255, 255, int(255 * 0.15)))
self._draw_content(self._rect.y)
class BigMultiParamToggle(BigMultiToggle):
def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable = None,
select_callback: Callable = None):
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
@@ -360,7 +391,7 @@ class BigMultiParamToggle(BigMultiToggle):
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable = None):
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()
@@ -376,8 +407,9 @@ class BigParamControl(BigToggle):
# TODO: param control base class
class BigCircleParamControl(BigCircleToggle):
def __init__(self, icon: str, param: str, toggle_callback: Callable = None):
super().__init__(icon, toggle_callback)
def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None,
icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, toggle_callback, icon_offset=icon_offset)
self._param = param
self.params = Params()
self.set_checked(self.params.get_bool(self._param, False))