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