forked from IQ.Lvbs/IQ.Pilot
IQ.Pilot Release Commit @ bec7652
This commit is contained in:
834
iqpilot/selfdrive/ui/mici/widgets/button.py
Normal file
834
iqpilot/selfdrive/ui/mici/widgets/button.py
Normal file
@@ -0,0 +1,834 @@
|
||||
"""
|
||||
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 iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import MiciLabel
|
||||
from iqpilot.system.ui.widgets.scroller import DO_ZOOM
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.common.filter_simple import BounceFilter
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
except ImportError:
|
||||
Params = None
|
||||
|
||||
try:
|
||||
from 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=[tr("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 iqpilot.selfdrive.ui.mici.widgets.drum_picker import DrumPickerDialog
|
||||
from iqpilot.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]))
|
||||
417
iqpilot/selfdrive/ui/mici/widgets/dialog.py
Normal file
417
iqpilot/selfdrive/ui/mici/widgets/dialog.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import abc
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
from iqpilot.system.ui.widgets import Widget, NavWidget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel, gui_label
|
||||
from iqpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
|
||||
from iqpilot.system.ui.widgets.scroller import Scroller
|
||||
from iqpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.selfdrive.ui.mici.widgets.button import BigButton
|
||||
from iqpilot.selfdrive.ui.mici.widgets.side_button import SideButton
|
||||
|
||||
DEBUG = False
|
||||
|
||||
PADDING = 20
|
||||
|
||||
|
||||
class BigDialogBase(NavWidget, abc.ABC):
|
||||
def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
self.set_back_callback(gui_app.pop_widget)
|
||||
|
||||
self._right_btn = None
|
||||
if right_btn:
|
||||
def right_btn_callback_wrapper():
|
||||
gui_app.pop_widget()
|
||||
if right_btn_callback:
|
||||
right_btn_callback()
|
||||
|
||||
self._right_btn = SideButton(right_btn)
|
||||
self._right_btn.set_click_callback(right_btn_callback_wrapper)
|
||||
# move to right side
|
||||
self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width
|
||||
|
||||
def _layout(self) -> None:
|
||||
rl.draw_rectangle_rec(rl.Rectangle(0, 0, gui_app.width, gui_app.height), rl.Color(8, 9, 10, 255))
|
||||
|
||||
def _render(self, _):
|
||||
if self._right_btn:
|
||||
self._right_btn.set_position(self._right_btn._rect.x, self._rect.y)
|
||||
self._right_btn.render()
|
||||
|
||||
|
||||
class BigDialog(BigDialogBase):
|
||||
def __init__(self,
|
||||
title: str,
|
||||
description: str,
|
||||
right_btn: str | None = None,
|
||||
right_btn_callback: Callable | None = None):
|
||||
super().__init__(right_btn, right_btn_callback)
|
||||
self._title = title
|
||||
self._description = description
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
|
||||
# draw title
|
||||
# TODO: we desperately need layouts
|
||||
# TODO: coming up with these numbers manually is a pain and not scalable
|
||||
# TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite
|
||||
max_width = self._rect.width - PADDING * 2
|
||||
if self._right_btn:
|
||||
max_width -= self._right_btn._rect.width
|
||||
|
||||
title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width)))
|
||||
title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50)
|
||||
text_x_offset = 0
|
||||
title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
|
||||
int(self._rect.y + PADDING),
|
||||
int(max_width),
|
||||
int(title_size.y))
|
||||
gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
# draw description
|
||||
desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width)))
|
||||
desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30)
|
||||
desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
|
||||
int(self._rect.y + self._rect.height / 3),
|
||||
int(max_width),
|
||||
int(desc_size.y))
|
||||
# TODO: text align doesn't seem to work properly with newlines
|
||||
gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
class BigConfirmationDialogV2(BigDialogBase):
|
||||
def __init__(self, title: str, icon: str, red: bool = False,
|
||||
exit_on_confirm: bool = True,
|
||||
confirm_callback: Callable | None = None):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._exit_on_confirm = exit_on_confirm
|
||||
|
||||
icon_txt = gui_app.texture(icon, 64, 53)
|
||||
self._slider: BigSlider | RedBigSlider
|
||||
if red:
|
||||
self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm)
|
||||
else:
|
||||
self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm)
|
||||
self._slider.set_enabled(lambda: self.enabled and not self._swiping_away) # self.enabled for nav stack
|
||||
|
||||
def _on_confirm(self):
|
||||
if self._exit_on_confirm:
|
||||
gui_app.pop_widget()
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self._swiping_away and not self._slider.confirmed:
|
||||
self._slider.reset()
|
||||
|
||||
def _render(self, _):
|
||||
self._slider.render(self._rect)
|
||||
|
||||
|
||||
class BigInputDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 0.2
|
||||
BACKSPACE_RATE = 25 # hz
|
||||
TEXT_INPUT_SIZE = 35
|
||||
|
||||
def __init__(self,
|
||||
hint: str,
|
||||
default_text: str = "",
|
||||
minimum_length: int = 1,
|
||||
confirm_callback: Callable[[str], None] | None = None):
|
||||
super().__init__(None, None)
|
||||
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
|
||||
font_weight=FontWeight.MEDIUM)
|
||||
self._keyboard = MiciKeyboard()
|
||||
self._keyboard.set_text(default_text)
|
||||
self._keyboard.set_enabled(lambda: self.enabled) # for nav stack
|
||||
self._minimum_length = minimum_length
|
||||
|
||||
self._backspace_held_time: float | None = None
|
||||
|
||||
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
|
||||
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 42, 36)
|
||||
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
# rects for top buttons
|
||||
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
def confirm_callback_wrapper():
|
||||
text = self._keyboard.text()
|
||||
gui_app.pop_widget()
|
||||
if confirm_callback:
|
||||
confirm_callback(text)
|
||||
self._confirm_callback = confirm_callback_wrapper
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
last_mouse_event = gui_app.last_mouse_event
|
||||
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
|
||||
if self._backspace_held_time is None:
|
||||
self._backspace_held_time = rl.get_time()
|
||||
|
||||
if rl.get_time() - self._backspace_held_time > 0.5:
|
||||
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
|
||||
self._keyboard.backspace()
|
||||
|
||||
else:
|
||||
self._backspace_held_time = None
|
||||
|
||||
def _render(self, _):
|
||||
# draw current text so far below everything. text floats left but always stays in view
|
||||
text = self._keyboard.text()
|
||||
candidate_char = self._keyboard.get_candidate_character()
|
||||
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
|
||||
|
||||
bg_block_margin = 5
|
||||
text_x = PADDING * 2 + self._enter_img.width + bg_block_margin
|
||||
text_field_rect = rl.Rectangle(text_x, int(self._rect.y + PADDING) - bg_block_margin,
|
||||
int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width) - bg_block_margin * 2,
|
||||
int(text_size.y))
|
||||
|
||||
# draw text input
|
||||
# push text left with a gradient on left side if too long
|
||||
if text_size.x > text_field_rect.width:
|
||||
text_x -= text_size.x - text_field_rect.width
|
||||
|
||||
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0, rl.WHITE)
|
||||
|
||||
# draw grayed out character user is hovering over
|
||||
if candidate_char:
|
||||
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
|
||||
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
|
||||
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, 128))
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# draw gradient on left side to indicate more text
|
||||
if text_size.x > text_field_rect.width:
|
||||
rl.draw_rectangle_gradient_h(int(text_field_rect.x), int(text_field_rect.y), 80, int(text_field_rect.height),
|
||||
rl.BLACK, rl.BLANK)
|
||||
|
||||
# draw cursor
|
||||
if text:
|
||||
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
|
||||
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_field_rect.y), 4, int(text_size.y)),
|
||||
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
|
||||
|
||||
# draw backspace icon with nice fade
|
||||
self._backspace_img_alpha.update(255 * bool(text))
|
||||
if self._backspace_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
|
||||
rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color)
|
||||
|
||||
if not text and self._hint_label.text and not candidate_char:
|
||||
# draw description if no text entered yet and not drawing candidate char
|
||||
self._hint_label.render(text_field_rect)
|
||||
|
||||
# TODO: move to update state
|
||||
# make rect take up entire area so it's easier to click
|
||||
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
|
||||
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
|
||||
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
|
||||
|
||||
self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35)
|
||||
if self._enter_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
|
||||
rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color)
|
||||
|
||||
# keyboard goes over everything
|
||||
self._keyboard.render(self._rect)
|
||||
|
||||
# draw debugging rect bounds
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
|
||||
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# TODO: need to track where press was so enter and back can activate on release rather than press
|
||||
# or turn into icon widgets :eyes_open:
|
||||
# handle backspace icon click
|
||||
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
|
||||
self._keyboard.backspace()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
|
||||
# handle enter icon click
|
||||
self._confirm_callback()
|
||||
|
||||
|
||||
class BigDialogOptionButton(Widget):
|
||||
HEIGHT = 64
|
||||
SELECTED_HEIGHT = 74
|
||||
|
||||
def __init__(self, option: str):
|
||||
super().__init__()
|
||||
self.option = option
|
||||
self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), self.HEIGHT))
|
||||
|
||||
self._selected = False
|
||||
|
||||
self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
|
||||
font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
scroll=True)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._label.reset_scroll()
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
self._selected = selected
|
||||
self._rect.height = self.SELECTED_HEIGHT if selected else self.HEIGHT
|
||||
|
||||
def _render(self, _):
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0x12, 0x97, 0x91, 0xFF))
|
||||
|
||||
# FIXME: offset x by -45 because scroller centers horizontally
|
||||
if self._selected:
|
||||
self._label.set_font_size(self.SELECTED_HEIGHT)
|
||||
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
|
||||
self._label.set_font_weight(FontWeight.DISPLAY)
|
||||
else:
|
||||
self._label.set_font_size(self.HEIGHT)
|
||||
self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
|
||||
self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
|
||||
|
||||
self._label.render(self._rect)
|
||||
|
||||
|
||||
class BigMultiOptionDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 0.1
|
||||
|
||||
def __init__(self, options: list[str], default: str | None,
|
||||
right_btn: str | None = 'check', right_btn_callback: Callable[[], None] | None = None):
|
||||
super().__init__(right_btn, right_btn_callback=right_btn_callback)
|
||||
self._options = options
|
||||
if default is not None:
|
||||
assert default in options
|
||||
|
||||
self._default_option: str | None = default
|
||||
self._selected_option: str = self._default_option or (options[0] if len(options) > 0 else "")
|
||||
self._last_selected_option: str = self._selected_option
|
||||
|
||||
# Widget doesn't differentiate between click and drag
|
||||
self._can_click = True
|
||||
|
||||
self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0, snap_items=True)
|
||||
if self._right_btn is not None:
|
||||
self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed)
|
||||
|
||||
for option in options:
|
||||
self._scroller.add_widget(BigDialogOptionButton(option))
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroller.show_event()
|
||||
if self._default_option is not None:
|
||||
self._on_option_selected(self._default_option)
|
||||
|
||||
def get_selected_option(self) -> str:
|
||||
return self._selected_option
|
||||
|
||||
def _on_option_selected(self, option: str):
|
||||
y_pos = 0.0
|
||||
for btn in self._scroller._items:
|
||||
btn = cast(BigDialogOptionButton, btn)
|
||||
if btn.option == option:
|
||||
rect_center_y = self._rect.y + self._rect.height / 2
|
||||
if btn._selected:
|
||||
height = btn.rect.height
|
||||
else:
|
||||
# when selecting an option under current, account for changing heights
|
||||
btn_center_y = btn.rect.y + btn.rect.height / 2 # not accurate, just to determine direction
|
||||
height_offset = BigDialogOptionButton.SELECTED_HEIGHT - BigDialogOptionButton.HEIGHT
|
||||
height = (BigDialogOptionButton.HEIGHT - height_offset) if rect_center_y < btn_center_y else BigDialogOptionButton.SELECTED_HEIGHT
|
||||
y_pos = rect_center_y - (btn.rect.y + height / 2)
|
||||
break
|
||||
|
||||
self._scroller.scroll_to(-y_pos)
|
||||
|
||||
def _selected_option_changed(self):
|
||||
pass
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
self._can_click = True
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
# # TODO: add generic _handle_mouse_click handler to Widget
|
||||
if not self._scroller.scroll_panel.is_touch_valid():
|
||||
self._can_click = False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
if not self._can_click:
|
||||
return
|
||||
|
||||
# select current option
|
||||
for btn in self._scroller._items:
|
||||
btn = cast(BigDialogOptionButton, btn)
|
||||
if btn.option == self._selected_option:
|
||||
self._on_option_selected(btn.option)
|
||||
break
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# get selection by whichever button is closest to center
|
||||
center_y = self._rect.y + self._rect.height / 2
|
||||
closest_btn = (None, float('inf'))
|
||||
for btn in self._scroller._items:
|
||||
dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y)
|
||||
if dist_y < closest_btn[1]:
|
||||
closest_btn = (btn, dist_y)
|
||||
|
||||
if closest_btn[0]:
|
||||
for btn in self._scroller._items:
|
||||
btn.set_selected(btn.option == closest_btn[0].option)
|
||||
self._selected_option = closest_btn[0].option
|
||||
|
||||
# Signal to subclasses if selection changed
|
||||
if self._selected_option != self._last_selected_option:
|
||||
self._selected_option_changed()
|
||||
self._last_selected_option = self._selected_option
|
||||
|
||||
def _render(self, _):
|
||||
super()._render(_)
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
|
||||
class BigDialogButton(BigButton):
|
||||
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
|
||||
super().__init__(text, value, icon)
|
||||
self._description = description
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
dlg = BigDialog(self.text, self._description)
|
||||
gui_app.push_widget(dlg)
|
||||
265
iqpilot/selfdrive/ui/mici/widgets/drum_picker.py
Normal file
265
iqpilot/selfdrive/ui/mici/widgets/drum_picker.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
DrumPickerDialog — iOS-style drum-roll value selector.
|
||||
|
||||
Layout (matches concept image):
|
||||
- Title label centred at top with a white underline
|
||||
- 5 visible values: [n-2] [n-1] [ N ] [n+1] [n+2]
|
||||
- Centre value: large bold white, flanked by two thin vertical bars
|
||||
- Outer values fade with distance (alpha 0.35 → 0.22 → 0.10)
|
||||
- Optional unit label below centre
|
||||
- Drag left/right or tap arrows to change value; confirm on release / tap elsewhere
|
||||
"""
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
|
||||
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.widgets import DialogResult
|
||||
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase
|
||||
|
||||
try:
|
||||
from iqpilot.ui.theme import NeonTheme
|
||||
except ImportError:
|
||||
class _FT:
|
||||
def glow(self, a=255): return rl.Color(0, 255, 245, a)
|
||||
def glow_outer(self, a=45): return rl.Color(0, 255, 245, a)
|
||||
NeonTheme = _FT()
|
||||
|
||||
|
||||
# ── Visual constants ───────────────────────────────────────────────────────────
|
||||
_CENTRE_FONT_SIZE = 36 # fits comfortably inside the slot walls
|
||||
_SIDE1_FONT_SIZE = 30 # one step away
|
||||
_SIDE2_FONT_SIZE = 20 # two steps away
|
||||
_UNIT_FONT_SIZE = 18
|
||||
_TITLE_FONT_SIZE = 24
|
||||
|
||||
_CENTRE_ALPHA = 255
|
||||
_SIDE1_ALPHA = int(255 * 0.45)
|
||||
_SIDE2_ALPHA = int(255 * 0.18)
|
||||
|
||||
_BAR_W = 2 # vertical separator bar width
|
||||
_BAR_H_FRAC = 0.55 # bar height as fraction of dialog height
|
||||
_SLOT_W = 90 # width of the centre slot (determines bar positions)
|
||||
_SLOT_SPACING = 115 # horizontal distance between adjacent value centres
|
||||
# 1 index = 1 slot spacing in pixels — drag exactly one slot width to move one step
|
||||
_DRAG_SCALE = 1.0 / _SLOT_SPACING
|
||||
|
||||
|
||||
class DrumPickerDialog(BigDialogBase):
|
||||
"""
|
||||
Full-screen drum-roll value picker.
|
||||
Drag left/right to scroll values; releasing snaps and calls confirm_callback
|
||||
immediately (live preview) but keeps the dialog open.
|
||||
Swipe down (back gesture) to dismiss.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
title: str,
|
||||
options: list[str],
|
||||
current: str,
|
||||
unit: str = "",
|
||||
confirm_callback: Callable[[str], None] | None = None):
|
||||
super().__init__()
|
||||
assert len(options) > 0
|
||||
self._title = title
|
||||
self._options = options
|
||||
self._unit = unit
|
||||
self._confirm_callback = confirm_callback
|
||||
|
||||
# Current index with a smooth bounce filter for animation
|
||||
try:
|
||||
idx = options.index(current)
|
||||
except ValueError:
|
||||
idx = 0
|
||||
dt = 1 / gui_app.target_fps
|
||||
self._idx: float = float(idx)
|
||||
self._idx_filter = BounceFilter(float(idx), 0.06, dt, bounce=3)
|
||||
self._idx_filter.x = float(idx)
|
||||
|
||||
# Press/release zoom scale — smoothly goes 1.0 → 1.12 on touch-down,
|
||||
# back to 1.0 on release, giving a tactile "grab" feel.
|
||||
self._scale_target: float = 1.0
|
||||
self._scale_filter = FirstOrderFilter(1.0, 0.04, dt)
|
||||
|
||||
# Drag state
|
||||
self._drag_start_x: float | None = None
|
||||
self._drag_start_idx: float = float(idx)
|
||||
self._dragging: bool = False
|
||||
|
||||
# Pre-load fonts
|
||||
self._font_display = gui_app.font(FontWeight.DISPLAY)
|
||||
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
||||
self._font_roman = gui_app.font(FontWeight.ROMAN)
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _current_idx(self) -> int:
|
||||
return max(0, min(round(self._idx_filter.x), len(self._options) - 1))
|
||||
|
||||
def selected_value(self) -> str:
|
||||
return self._options[self._current_idx()]
|
||||
|
||||
def _clamp_idx(self, v: float) -> float:
|
||||
return max(0.0, min(v, float(len(self._options) - 1)))
|
||||
|
||||
# ── Input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# Kill any in-progress bounce so the grab starts from exactly
|
||||
# where the value visually is right now — no jump on first drag.
|
||||
snapped = float(round(self._idx_filter.x))
|
||||
self._idx = snapped
|
||||
self._idx_filter.x = snapped
|
||||
self._idx_filter.velocity.x = 0.0 # kill bounce velocity
|
||||
self._drag_start_x = mouse_pos.x
|
||||
self._drag_start_idx = snapped
|
||||
self._dragging = False
|
||||
self._scale_target = 1.12 # zoom in on touch-down
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
if self._drag_start_x is None:
|
||||
return
|
||||
delta = self._drag_start_x - mouse_event.pos.x # drag left = higher idx
|
||||
if abs(delta) > 8:
|
||||
self._dragging = True
|
||||
if self._dragging:
|
||||
self._idx = self._clamp_idx(self._drag_start_idx + delta * _DRAG_SCALE)
|
||||
self._idx_filter.x = self._idx # snap immediately while dragging
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
# Swipe-down = dismiss, calling callback with final value first
|
||||
if self._swiping_away:
|
||||
self._idx = float(round(self._idx_filter.x))
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback(self.selected_value())
|
||||
self._drag_start_x = None
|
||||
self._dragging = False
|
||||
self._ret = DialogResult.CONFIRM
|
||||
return
|
||||
# Normal release: set the snap target and let BounceFilter glide there.
|
||||
# Do NOT hard-set _idx_filter.x — that would teleport instead of animate.
|
||||
self._idx = float(round(self._idx_filter.x))
|
||||
self._drag_start_x = None
|
||||
self._dragging = False
|
||||
self._scale_target = 1.0 # zoom back out on release
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback(self.selected_value())
|
||||
|
||||
# ── Update ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
# While dragging, filter is slaved directly to _idx (instant follow).
|
||||
# On release, _dragging=False so filter animates toward _idx with bounce.
|
||||
if self._dragging:
|
||||
self._idx_filter.x = self._idx
|
||||
self._idx_filter.velocity.x = 0.0
|
||||
else:
|
||||
self._idx_filter.update(self._idx)
|
||||
self._scale_filter.update(self._scale_target)
|
||||
|
||||
# ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _render(self, _) -> DialogResult:
|
||||
rect = self._rect
|
||||
cx = rect.x + rect.width / 2
|
||||
cy = rect.y + rect.height / 2
|
||||
|
||||
# ── Dark background ───────────────────────────────────────────────────────
|
||||
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height),
|
||||
rl.Color(0, 0, 0, 230))
|
||||
|
||||
# ── Vertical separator bars (drawn first so title renders above) ──────────
|
||||
bar_h = int(rect.height * _BAR_H_FRAC)
|
||||
bar_y = int(cy - bar_h / 2)
|
||||
bar_col = NeonTheme.glow(60)
|
||||
rl.draw_rectangle(int(cx - _SLOT_W / 2), bar_y, _BAR_W, bar_h, bar_col)
|
||||
rl.draw_rectangle(int(cx + _SLOT_W / 2), bar_y, _BAR_W, bar_h, bar_col)
|
||||
|
||||
# ── Title — centred above the bars, no underline ──────────────────────────
|
||||
title_w = int(measure_text_cached(self._font_medium, self._title, _TITLE_FONT_SIZE).x)
|
||||
tx = int(cx - title_w / 2)
|
||||
ty = int(rect.y + (bar_y - rect.y) / 2 - _TITLE_FONT_SIZE / 2)
|
||||
rl.draw_text_ex(self._font_medium, self._title,
|
||||
rl.Vector2(tx, ty), _TITLE_FONT_SIZE, 0,
|
||||
rl.Color(255, 255, 255, 220))
|
||||
|
||||
# ── Value row ─────────────────────────────────────────────────────────────
|
||||
animated_idx = self._idx_filter.x
|
||||
n = len(self._options)
|
||||
|
||||
# centre_int is always the settled target — never flips mid-animation.
|
||||
# frac drives pixel offset only (how far filter still needs to travel).
|
||||
centre_int = int(self._idx)
|
||||
frac = animated_idx - centre_int
|
||||
|
||||
for offset in [-2, -1, 0, 1, 2]:
|
||||
# Which option index lives in this visual slot?
|
||||
opt_idx = centre_int + offset
|
||||
if opt_idx < 0 or opt_idx >= n:
|
||||
continue
|
||||
|
||||
# Pixel offset from screen centre: slot position minus fractional drift
|
||||
draw_x_off = (offset - frac) * _SLOT_SPACING
|
||||
|
||||
label = self._options[opt_idx]
|
||||
abs_offset = abs(offset - frac) # fractional distance from visual centre
|
||||
|
||||
# Font size and alpha interpolated by distance
|
||||
if abs_offset < 0.5:
|
||||
font_sz = int(_SIDE1_FONT_SIZE + (_CENTRE_FONT_SIZE - _SIDE1_FONT_SIZE) * (1 - abs_offset * 2))
|
||||
alpha = int(_SIDE1_ALPHA + (_CENTRE_ALPHA - _SIDE1_ALPHA) * (1 - abs_offset * 2))
|
||||
weight = FontWeight.DISPLAY
|
||||
elif abs_offset < 1.5:
|
||||
t = abs_offset - 0.5
|
||||
font_sz = int(_SIDE2_FONT_SIZE + (_SIDE1_FONT_SIZE - _SIDE2_FONT_SIZE) * (1 - t))
|
||||
alpha = int(_SIDE2_ALPHA + (_SIDE1_ALPHA - _SIDE2_ALPHA) * (1 - t))
|
||||
weight = FontWeight.DISPLAY
|
||||
else:
|
||||
font_sz = _SIDE2_FONT_SIZE
|
||||
alpha = _SIDE2_ALPHA
|
||||
weight = FontWeight.DISPLAY
|
||||
|
||||
# Apply press-zoom scale to the centre value only
|
||||
scale = 1.0 + (self._scale_filter.x - 1.0) * max(0.0, 1.0 - abs_offset * 2)
|
||||
font_sz = max(8, int(font_sz * scale))
|
||||
|
||||
font = gui_app.font(weight)
|
||||
tw = int(measure_text_cached(font, label, font_sz).x)
|
||||
th = font_sz
|
||||
draw_x = int(cx + draw_x_off - tw / 2)
|
||||
draw_y = int(cy - th / 2)
|
||||
|
||||
rl.draw_text_ex(font, label,
|
||||
rl.Vector2(draw_x, draw_y),
|
||||
font_sz, 0,
|
||||
rl.Color(255, 255, 255, alpha))
|
||||
|
||||
# ── Unit label below centre ───────────────────────────────────────────────
|
||||
if self._unit:
|
||||
uw = int(measure_text_cached(self._font_roman, self._unit, _UNIT_FONT_SIZE).x)
|
||||
rl.draw_text_ex(self._font_roman, self._unit,
|
||||
rl.Vector2(int(cx - uw / 2), int(cy + _CENTRE_FONT_SIZE / 2 + 8)),
|
||||
_UNIT_FONT_SIZE, 0,
|
||||
rl.Color(255, 255, 255, 140))
|
||||
|
||||
# ── Subtle neon glow on centre slot ───────────────────────────────────────
|
||||
slot_rect = rl.Rectangle(cx - _SLOT_W / 2 - 1, bar_y, _SLOT_W + 2, bar_h)
|
||||
rl.draw_rectangle_gradient_h(
|
||||
int(slot_rect.x), int(slot_rect.y),
|
||||
int(slot_rect.width // 2), int(slot_rect.height),
|
||||
rl.BLANK, NeonTheme.glow_outer(40),
|
||||
)
|
||||
rl.draw_rectangle_gradient_h(
|
||||
int(slot_rect.x + slot_rect.width // 2), int(slot_rect.y),
|
||||
int(slot_rect.width // 2), int(slot_rect.height),
|
||||
NeonTheme.glow_outer(40), rl.BLANK,
|
||||
)
|
||||
|
||||
return self._ret
|
||||
196
iqpilot/selfdrive/ui/mici/widgets/pairing_dialog.py
Normal file
196
iqpilot/selfdrive/ui/mici/widgets/pairing_dialog.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import pyray as rl
|
||||
import qrcode
|
||||
import numpy as np
|
||||
import time
|
||||
import jwt
|
||||
import os
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from iqpilot.common.api.base import BaseApi
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.konn3kt.registration import get_or_create_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import HARDWARE, PC
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.widgets import NavWidget
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.widgets.label import MiciLabel
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class PairingDialog(NavWidget):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
|
||||
self._params = Params()
|
||||
self._qr_texture: rl.Texture | None = None
|
||||
self._last_qr_generation = float("-inf")
|
||||
|
||||
self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64)
|
||||
self._pair_label = MiciLabel(tr("pair with Konn3kt"), 48, font_weight=FontWeight.BOLD,
|
||||
color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
dev_pairing = PC and os.getenv("KONN3KT_DEV_PAIRING") == "1"
|
||||
if dev_pairing:
|
||||
try:
|
||||
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
|
||||
except Exception:
|
||||
return "error://dev_identity_setup_failed"
|
||||
|
||||
try:
|
||||
imei1 = HARDWARE.get_imei(0) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei1: {e}")
|
||||
imei1 = ""
|
||||
|
||||
try:
|
||||
imei2 = HARDWARE.get_imei(1) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei2: {e}")
|
||||
imei2 = ""
|
||||
|
||||
try:
|
||||
algorithm, private_key, public_key = BaseApi.get_key_pair()
|
||||
if not private_key or not algorithm:
|
||||
cloudlog.error("No device keys found")
|
||||
return "error://keys_not_found"
|
||||
|
||||
dongle_id = get_or_create_dongle_id(self._params, prefer_readonly=True)
|
||||
|
||||
try:
|
||||
serial = HARDWARE.get_serial() or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get serial: {e}")
|
||||
serial = ""
|
||||
if not serial:
|
||||
serial = (self._params.get("HardwareSerial") or "") if dev_pairing else ""
|
||||
if not serial:
|
||||
cloudlog.error("No hardware serial found, cannot generate pairing token")
|
||||
return "error://serial_not_found"
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
payload = {
|
||||
'identity': dongle_id,
|
||||
'nbf': now,
|
||||
'iat': now,
|
||||
'imei': imei1,
|
||||
'imei2': imei2,
|
||||
'serial': serial,
|
||||
'public_key': public_key,
|
||||
'register': True,
|
||||
'exp': now + timedelta(hours=1),
|
||||
}
|
||||
|
||||
try:
|
||||
token = jwt.encode(payload, private_key, algorithm=algorithm)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"jwt.encode failed ({e}), retrying with normalized key")
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key_bytes = private_key.encode("utf-8") if isinstance(private_key, str) else private_key
|
||||
try:
|
||||
key_obj = serialization.load_pem_private_key(key_bytes, password=None)
|
||||
except Exception:
|
||||
key_obj = serialization.load_ssh_private_key(key_bytes, password=None)
|
||||
token = jwt.encode(payload, key_obj, algorithm=algorithm)
|
||||
except Exception as e2:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e2}")
|
||||
return "error://token_generation_failed"
|
||||
if isinstance(token, bytes):
|
||||
token = token.decode('utf8')
|
||||
return f"https://konn3kt.com/?pair={token}"
|
||||
except FileNotFoundError as e:
|
||||
cloudlog.error(f"Key files not found: {e}")
|
||||
return "error://keys_not_found"
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e}")
|
||||
return "error://token_generation_failed"
|
||||
|
||||
def _generate_qr_code(self) -> None:
|
||||
try:
|
||||
url = self._get_pairing_url()
|
||||
if url.startswith("error://"):
|
||||
cloudlog.warning(f"Cannot generate QR code: {url}")
|
||||
self._qr_texture = None
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
|
||||
img_array = np.array(pil_img, dtype=np.uint8)
|
||||
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
rl_image = rl.Image()
|
||||
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
|
||||
rl_image.width = pil_img.width
|
||||
rl_image.height = pil_img.height
|
||||
rl_image.mipmaps = 1
|
||||
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
|
||||
|
||||
self._qr_texture = rl.load_texture_from_image(rl_image)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"QR code generation failed: {e}")
|
||||
self._qr_texture = None
|
||||
|
||||
def _check_qr_refresh(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
|
||||
self._generate_qr_code()
|
||||
self._last_qr_generation = current_time
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if ui_state.prime_state.is_paired():
|
||||
self._playing_dismiss_animation = True
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> int:
|
||||
self._check_qr_refresh()
|
||||
|
||||
self._render_qr_code()
|
||||
|
||||
label_x = self._rect.x + 8 + self._rect.height + 24
|
||||
self._pair_label.set_width(int(self._rect.width - label_x))
|
||||
self._pair_label.set_position(label_x, self._rect.y + 16)
|
||||
self._pair_label.render()
|
||||
|
||||
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
|
||||
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
|
||||
|
||||
return -1
|
||||
|
||||
def _render_qr_code(self) -> None:
|
||||
if not self._qr_texture:
|
||||
error_font = gui_app.font(FontWeight.BOLD)
|
||||
rl.draw_text_ex(
|
||||
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
|
||||
)
|
||||
return
|
||||
|
||||
scale = self._rect.height / self._qr_texture.height
|
||||
pos = rl.Vector2(self._rect.x + 8, self._rect.y)
|
||||
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = PairingDialog()
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
if result != -1:
|
||||
break
|
||||
finally:
|
||||
del pairing
|
||||
31
iqpilot/selfdrive/ui/mici/widgets/side_button.py
Normal file
31
iqpilot/selfdrive/ui/mici/widgets/side_button.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import pyray as rl
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.lib.application import gui_app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants extracted from the original Qt style
|
||||
# ---------------------------------------------------------------------------
|
||||
# TODO: this should be corrected, but Scroller relies on this being incorrect :/
|
||||
WIDTH, HEIGHT = 112, 240
|
||||
|
||||
|
||||
class SideButton(Widget):
|
||||
def __init__(self, btn_type: str):
|
||||
super().__init__()
|
||||
self.type = btn_type
|
||||
self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT))
|
||||
|
||||
# load pre-rendered button images
|
||||
if btn_type not in ("check", "back"):
|
||||
btn_type = "back"
|
||||
btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png"
|
||||
btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png"
|
||||
self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224)
|
||||
|
||||
def _render(self, _) -> bool:
|
||||
x = int(self._rect.x + 12)
|
||||
y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2)
|
||||
rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back,
|
||||
x, y, rl.WHITE)
|
||||
|
||||
return False
|
||||
538
iqpilot/selfdrive/ui/mici/widgets/stock_button.py
Normal file
538
iqpilot/selfdrive/ui/mici/widgets/stock_button.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from enum import Enum
|
||||
from collections.abc import Callable
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.scroller import DO_ZOOM
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.common.filter_simple import BounceFilter
|
||||
from iqpilot.ui.theme import NeonTheme
|
||||
|
||||
try:
|
||||
from iqpilot.common.params import Params, UnknownKeyName
|
||||
except ImportError:
|
||||
Params = None
|
||||
class UnknownKeyName(Exception):
|
||||
pass
|
||||
|
||||
SCROLLING_SPEED_PX_S = 50
|
||||
COMPLICATION_SIZE = 36
|
||||
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
|
||||
|
||||
_FORCE_ACCENT_RGB = None
|
||||
|
||||
_ACCENT_ROUND = 0.34
|
||||
_ACCENT_INSET = 6
|
||||
_GLOW_OUT = 3
|
||||
_GLOW_IN = 12
|
||||
_GLOW_IN_IDLE = 4
|
||||
_GLOW_OUT_ALPHA = 32
|
||||
_GLOW_IN_ALPHA = 80
|
||||
_RIM_ALPHA = 200
|
||||
_GLOW_SEGS = 16
|
||||
_GLOW_CORNER_SEGS = 16
|
||||
|
||||
|
||||
def _accent_rgb() -> tuple[int, int, int]:
|
||||
if _FORCE_ACCENT_RGB is not None:
|
||||
return _FORCE_ACCENT_RGB
|
||||
c = NeonTheme.glow(255)
|
||||
return (c.r, c.g, c.b)
|
||||
|
||||
|
||||
def _inset(rect: rl.Rectangle, px: float) -> rl.Rectangle:
|
||||
return rl.Rectangle(rect.x + px, rect.y + px, rect.width - 2 * px, rect.height - 2 * px)
|
||||
|
||||
|
||||
_BOX_BG = rl.Color(0x08, 0x09, 0x0A, 255)
|
||||
_BOX_BG_PRESSED = rl.Color(0x16, 0x18, 0x1A, 255)
|
||||
_BOX_BG_DISABLED = rl.Color(0x05, 0x06, 0x07, 255)
|
||||
|
||||
|
||||
def _draw_accent_box(rect: rl.Rectangle, enabled: bool, pressed: bool):
|
||||
"""Clean dark rounded box + smooth teal glow (matches the concept). Same roundness
|
||||
for box and glow → no seam. `rect` is the final box rect."""
|
||||
base = 1.0 if enabled else 0.4
|
||||
r, g, b = _accent_rgb()
|
||||
|
||||
# keep every concentric ring's corner radius offset by exactly its inset, so the
|
||||
# rings stay parallel at the corners too (a fixed roundness fraction would shrink
|
||||
# the corner radius unevenly and leave a dark seam in each corner)
|
||||
shorter = min(rect.width, rect.height)
|
||||
base_radius = _ACCENT_ROUND * shorter / 2.0
|
||||
|
||||
def _round_for(short_side: float, radius: float) -> float:
|
||||
return max(0.0, min(1.0, 2.0 * radius / short_side)) if short_side > 0 else 0.0
|
||||
|
||||
for px in range(_GLOW_OUT, 0, -1):
|
||||
f = px / _GLOW_OUT
|
||||
a = int(_GLOW_OUT_ALPHA * base * (1.0 - f) ** 2.0)
|
||||
if a <= 0:
|
||||
continue
|
||||
ex = rl.Rectangle(rect.x - px, rect.y - px, rect.width + 2 * px, rect.height + 2 * px)
|
||||
rl.draw_rectangle_rounded(ex, _round_for(shorter + 2 * px, base_radius + px), _GLOW_SEGS, rl.Color(r, g, b, a))
|
||||
|
||||
bg = _BOX_BG_PRESSED if pressed else (_BOX_BG if enabled else _BOX_BG_DISABLED)
|
||||
rl.draw_rectangle_rounded(rect, _round_for(shorter, base_radius), _GLOW_SEGS, bg)
|
||||
|
||||
for px in range(_GLOW_IN, 0, -1):
|
||||
f = px / _GLOW_IN
|
||||
a = int(_GLOW_IN_ALPHA * base * (1.0 - f) ** 1.8)
|
||||
if a <= 0:
|
||||
continue
|
||||
rl.draw_rectangle_rounded_lines_ex(_inset(rect, px), _round_for(shorter - 2 * px, base_radius - px),
|
||||
_GLOW_CORNER_SEGS, 3, rl.Color(r, g, b, a))
|
||||
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, _round_for(shorter, base_radius), _GLOW_CORNER_SEGS, 2,
|
||||
rl.Color(r, g, b, int(_RIM_ALPHA * base)))
|
||||
|
||||
|
||||
_CIRCLE_RED_RGB = (0xE0, 0x3A, 0x3A)
|
||||
|
||||
|
||||
def _draw_accent_circle(cx: float, cy: float, radius: float, enabled: bool, red: bool = False, pressed: bool = False):
|
||||
"""Circular version of the box accent: teal (or red) rim + inward glow, matching the boxes."""
|
||||
base = 1.0 if enabled else 0.4
|
||||
r, g, b = _CIRCLE_RED_RGB if red else _accent_rgb()
|
||||
c = rl.Vector2(cx, cy)
|
||||
segs = _GLOW_CORNER_SEGS * 2
|
||||
for px in range(_GLOW_OUT, 0, -1):
|
||||
a = int(_GLOW_OUT_ALPHA * base * (1.0 - px / _GLOW_OUT) ** 2.0)
|
||||
if a > 0:
|
||||
rl.draw_ring(c, radius + px - 1.0, radius + px + 1.0, 0, 360, segs, rl.Color(r, g, b, a))
|
||||
glow_in = _GLOW_IN if pressed else _GLOW_IN_IDLE
|
||||
for px in range(glow_in, 0, -1):
|
||||
a = int(_GLOW_IN_ALPHA * base * (1.0 - px / glow_in) ** 1.8)
|
||||
if a > 0:
|
||||
rl.draw_ring(c, radius - px - 1.5, radius - px + 1.5, 0, 360, segs, rl.Color(r, g, b, a))
|
||||
rl.draw_ring(c, radius - 1.5, radius + 1.5, 0, 360, segs, rl.Color(r, g, b, int(_RIM_ALPHA * base)))
|
||||
|
||||
|
||||
class ScrollState(Enum):
|
||||
PRE_SCROLL = 0
|
||||
SCROLLING = 1
|
||||
POST_SCROLL = 2
|
||||
|
||||
|
||||
class BigCircleButton(Widget):
|
||||
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
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, 180, 180))
|
||||
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._click_delay = 0.075
|
||||
|
||||
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_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_pressed.png", 180, 180)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
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, (btn_x + (btn_width - self._txt_icon.width) / 2 + self._icon_offset[0],
|
||||
btn_y + (btn_height - self._txt_icon.height) / 2 + self._icon_offset[1]), 0, 1.0, icon_color)
|
||||
|
||||
def _render(self, _):
|
||||
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:
|
||||
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 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)
|
||||
|
||||
cx = btn_x + self._rect.width * scale / 2.0
|
||||
cy = btn_y + self._rect.height * scale / 2.0
|
||||
_draw_accent_circle(cx, cy, self._rect.width * scale / 2.0 - _ACCENT_INSET, self.enabled, self._red, self.is_pressed)
|
||||
|
||||
self._draw_content(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
|
||||
|
||||
|
||||
class BigCircleToggle(BigCircleButton):
|
||||
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
|
||||
|
||||
self._checked = False
|
||||
|
||||
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 _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
rl.draw_texture_ex(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
|
||||
(btn_x + (btn_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
|
||||
|
||||
"""A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
|
||||
|
||||
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._txt_icon = icon
|
||||
self._scroll = scroll
|
||||
self._press_effect_enabled = True
|
||||
|
||||
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 = 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,
|
||||
wrap_text=False, scroll=True)
|
||||
self._update_label_layout()
|
||||
|
||||
self._load_images()
|
||||
|
||||
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:
|
||||
return
|
||||
self._rotate_icon_t = rl.get_time() if rotate else None
|
||||
|
||||
def set_press_effect_enabled(self, enabled: bool) -> None:
|
||||
self._press_effect_enabled = enabled
|
||||
|
||||
def set_scroll_active(self, active: bool) -> None:
|
||||
self._label.set_scroll_active(active)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
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) <= 18:
|
||||
return 48
|
||||
else:
|
||||
return 42
|
||||
|
||||
def _update_label_layout(self):
|
||||
self._label.set_font_size(self._get_label_font_size())
|
||||
if self.value:
|
||||
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._update_label_layout()
|
||||
|
||||
def set_value(self, value: str):
|
||||
self.value = value
|
||||
self._sub_label.set_text(value)
|
||||
self._update_label_layout()
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self.value
|
||||
|
||||
def get_text(self):
|
||||
return self.text
|
||||
|
||||
def trigger_shake(self):
|
||||
self._shake_start = rl.get_time()
|
||||
|
||||
def trigger_grow_animation(self, duration: float = 0.65):
|
||||
self._grow_animation_until = rl.get_time() + duration
|
||||
|
||||
@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)
|
||||
|
||||
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
|
||||
|
||||
txt_bg = self._txt_default_bg
|
||||
if not self.enabled:
|
||||
txt_bg = self._txt_disabled_bg
|
||||
elif self.is_pressed and self._press_effect_enabled:
|
||||
txt_bg = self._txt_pressed_bg
|
||||
|
||||
pressed_scale = self.is_pressed and self._press_effect_enabled
|
||||
animate_scale = pressed_scale or self._grow_animation_until is not None
|
||||
scale = self._scale_filter.update(PRESSED_SCALE if animate_scale 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
|
||||
return txt_bg, btn_x, btn_y, scale
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
label_x = btn_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)
|
||||
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
|
||||
btn_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 + btn_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)
|
||||
|
||||
if self._txt_icon:
|
||||
rotation = 0
|
||||
if self._rotate_icon_t is not None:
|
||||
rotation = (rl.get_time() - self._rotate_icon_t) * 180
|
||||
|
||||
x = btn_x + btn_width - 30 - self._txt_icon.width / 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(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.Color(255, 255, 255, int(255 * 0.9)))
|
||||
|
||||
def _render(self, _):
|
||||
txt_bg, btn_x, btn_y, scale = self._handle_background()
|
||||
|
||||
cell = rl.Rectangle(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale)
|
||||
box_rect = _inset(cell, _ACCENT_INSET)
|
||||
_draw_accent_box(box_rect, self.enabled, self.is_pressed)
|
||||
|
||||
# Clip each card's content to its own bounds so long/scrolling labels from one
|
||||
# tile cannot bleed into neighboring tiles in the horizontal scroller.
|
||||
content_rect = _inset(box_rect, 2)
|
||||
rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height))
|
||||
self._draw_content(btn_x, btn_y, cell.width, cell.height)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 _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)
|
||||
self._checked = not self._checked
|
||||
if self._toggle_callback:
|
||||
self._toggle_callback(self._checked)
|
||||
|
||||
def _draw_pill(self, x: float, y: float, checked: bool):
|
||||
if checked:
|
||||
rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE)
|
||||
else:
|
||||
rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE)
|
||||
|
||||
def _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
super()._draw_content(btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
x = btn_x + btn_width - self._txt_enabled_toggle.width
|
||||
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 = 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.set_value(self._options[0])
|
||||
|
||||
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)
|
||||
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 _draw_content(self, btn_x: float, btn_y: float, btn_width: float, btn_height: float):
|
||||
BigButton._draw_content(self, btn_x, btn_y, btn_width, btn_height)
|
||||
|
||||
checked_idx = self._options.index(self.value)
|
||||
|
||||
x = btn_x + btn_width - self._txt_enabled_toggle.width
|
||||
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.x, self._rect.y, self._rect.width, self._rect.height)
|
||||
|
||||
|
||||
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(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._read_bool())
|
||||
|
||||
def _read_bool(self) -> bool:
|
||||
try:
|
||||
return self.params.get_bool(self.param)
|
||||
except UnknownKeyName:
|
||||
return False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
try:
|
||||
self.params.put_bool(self.param, self._checked)
|
||||
except UnknownKeyName:
|
||||
pass
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self._read_bool())
|
||||
|
||||
|
||||
class BigCircleParamControl(BigCircleToggle):
|
||||
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._read_bool())
|
||||
|
||||
def _read_bool(self) -> bool:
|
||||
try:
|
||||
return self.params.get_bool(self._param)
|
||||
except UnknownKeyName:
|
||||
return False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
try:
|
||||
self.params.put_bool(self._param, self._checked)
|
||||
except UnknownKeyName:
|
||||
pass
|
||||
|
||||
def refresh(self):
|
||||
self.set_checked(self._read_bool())
|
||||
264
iqpilot/selfdrive/ui/mici/widgets/stock_dialog.py
Normal file
264
iqpilot/selfdrive/ui/mici/widgets/stock_dialog.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import abc
|
||||
import math
|
||||
import pyray as rl
|
||||
from typing import Union
|
||||
from collections.abc import Callable
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from iqpilot.system.ui.lib.raylib_compat import draw_rectangle_gradient_ex
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
|
||||
from iqpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from iqpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
|
||||
from iqpilot.common.filter_simple import FirstOrderFilter
|
||||
from iqpilot.selfdrive.ui.mici.widgets.stock_button import BigCircleButton, BigButton, GreyBigButton
|
||||
|
||||
DEBUG = False
|
||||
|
||||
PADDING = 20
|
||||
|
||||
|
||||
class BigDialogBase(NavWidget, abc.ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
|
||||
|
||||
class BigDialog(BigDialogBase):
|
||||
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
|
||||
super().__init__()
|
||||
self._card = GreyBigButton(title, description, icon)
|
||||
|
||||
def _render(self, _):
|
||||
self._card.render(rl.Rectangle(
|
||||
self._rect.x + self._rect.width / 2 - self._card.rect.width / 2,
|
||||
self._rect.y + self._rect.height / 2 - self._card.rect.height / 2,
|
||||
self._card.rect.width,
|
||||
self._card.rect.height,
|
||||
))
|
||||
|
||||
|
||||
class BigConfirmationDialog(BigDialogBase):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None],
|
||||
exit_on_confirm: bool = True, red: bool = False):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._exit_on_confirm = exit_on_confirm
|
||||
|
||||
self._slider: BigSlider | RedBigSlider
|
||||
if red:
|
||||
self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm))
|
||||
else:
|
||||
self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm))
|
||||
self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
|
||||
|
||||
def _on_confirm(self):
|
||||
if self._exit_on_confirm:
|
||||
self.dismiss(self._confirm_callback)
|
||||
elif self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if self.is_dismissing and not self._slider.confirmed:
|
||||
self._slider.reset()
|
||||
|
||||
def _render(self, _):
|
||||
self._slider.render(self._rect)
|
||||
|
||||
|
||||
class BigInputDialog(BigDialogBase):
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 1.0
|
||||
BACKSPACE_RATE = 25 # hz
|
||||
TEXT_INPUT_SIZE = 35
|
||||
INTRO_DURATION_S = 0.14
|
||||
INTRO_OFFSET_Y = 20
|
||||
|
||||
def __init__(self,
|
||||
hint: str,
|
||||
default_text: str = "",
|
||||
minimum_length: int = 1,
|
||||
confirm_callback: Callable[[str], None] | None = None,
|
||||
auto_return_to_letters: str = ""):
|
||||
super().__init__()
|
||||
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
|
||||
font_weight=FontWeight.MEDIUM)
|
||||
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
|
||||
self._keyboard.set_text(default_text)
|
||||
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
|
||||
self._minimum_length = minimum_length
|
||||
|
||||
self._backspace_held_time: float | None = None
|
||||
|
||||
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
|
||||
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/enter.png", 76, 62)
|
||||
self._enter_disabled_img = gui_app.texture("icons_mici/settings/keyboard/enter_disabled.png", 76, 62)
|
||||
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
# rects for top buttons
|
||||
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._intro_started_at = 0.0
|
||||
|
||||
def confirm_callback_wrapper():
|
||||
text = self._keyboard.text()
|
||||
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
|
||||
self._confirm_callback = confirm_callback_wrapper
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self.settle_to_top()
|
||||
self._intro_started_at = rl.get_time()
|
||||
|
||||
def _intro_progress(self) -> float:
|
||||
if self._intro_started_at <= 0.0:
|
||||
return 1.0
|
||||
return min(max((rl.get_time() - self._intro_started_at) / self.INTRO_DURATION_S, 0.0), 1.0)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
if self.is_dismissing:
|
||||
self._backspace_held_time = None
|
||||
return
|
||||
|
||||
last_mouse_event = gui_app.last_mouse_event
|
||||
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
|
||||
if self._backspace_held_time is None:
|
||||
self._backspace_held_time = rl.get_time()
|
||||
|
||||
if rl.get_time() - self._backspace_held_time > 0.5:
|
||||
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
|
||||
self._keyboard.backspace()
|
||||
|
||||
else:
|
||||
self._backspace_held_time = None
|
||||
|
||||
def _render(self, _):
|
||||
intro_progress = self._intro_progress()
|
||||
intro_offset_y = (1.0 - intro_progress) * self.INTRO_OFFSET_Y
|
||||
intro_alpha = max(int(255 * intro_progress), 1)
|
||||
|
||||
# draw current text so far below everything. text floats left but always stays in view
|
||||
text = self._keyboard.text()
|
||||
candidate_char = self._keyboard.get_candidate_character()
|
||||
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE)
|
||||
|
||||
bg_block_margin = 5
|
||||
text_x = PADDING / 2 + self._enter_img.width + PADDING
|
||||
text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin + intro_offset_y,
|
||||
self._rect.width - text_x * 2,
|
||||
text_size.y)
|
||||
|
||||
# draw text input
|
||||
# push text left with a gradient on left side if too long
|
||||
if text_size.x > text_field_rect.width:
|
||||
text_x -= text_size.x - text_field_rect.width
|
||||
|
||||
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0,
|
||||
rl.Color(255, 255, 255, intro_alpha))
|
||||
|
||||
# draw grayed out character user is hovering over
|
||||
if candidate_char:
|
||||
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE)
|
||||
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
|
||||
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
|
||||
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, int(128 * intro_progress)))
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# draw gradient on left side to indicate more text
|
||||
if text_size.x > text_field_rect.width:
|
||||
draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height),
|
||||
rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK)
|
||||
|
||||
# draw cursor
|
||||
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
|
||||
if text:
|
||||
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
|
||||
else:
|
||||
cursor_x = text_field_rect.x - 6
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y),
|
||||
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha * intro_progress)))
|
||||
|
||||
# draw backspace icon with nice fade
|
||||
self._backspace_img_alpha.update(255 * bool(text))
|
||||
if self._backspace_img_alpha.x > 1:
|
||||
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x * intro_progress))
|
||||
rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14 + intro_offset_y), 0.0, 1.0, color)
|
||||
|
||||
if not text and self._hint_label.text and not candidate_char:
|
||||
# draw description if no text entered yet and not drawing candidate char
|
||||
hint_rect = rl.Rectangle(text_field_rect.x, text_field_rect.y,
|
||||
self._rect.width - text_field_rect.x - PADDING,
|
||||
text_field_rect.height)
|
||||
self._hint_label.set_color(rl.Color(255, 255, 255, int(255 * 0.35 * intro_progress)))
|
||||
self._hint_label.render(hint_rect)
|
||||
|
||||
# TODO: move to update state
|
||||
# make rect take up entire area so it's easier to click
|
||||
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
|
||||
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
|
||||
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
|
||||
|
||||
# draw enter button
|
||||
self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0)
|
||||
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x * intro_progress))
|
||||
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y + intro_offset_y), 0.0, 1.0, color)
|
||||
color = rl.Color(255, 255, 255, int((255 - self._enter_img_alpha.x) * intro_progress))
|
||||
rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y + intro_offset_y), 0.0, 1.0, color)
|
||||
|
||||
# keyboard goes over everything
|
||||
self._keyboard.render(rl.Rectangle(self._rect.x, self._rect.y + intro_offset_y, self._rect.width, self._rect.height))
|
||||
|
||||
# draw debugging rect bounds
|
||||
if DEBUG:
|
||||
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
|
||||
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_press(mouse_pos)
|
||||
# TODO: need to track where press was so enter and back can activate on release rather than press
|
||||
# or turn into icon widgets :eyes_open:
|
||||
|
||||
if self.is_dismissing:
|
||||
return
|
||||
|
||||
# handle backspace icon click
|
||||
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
|
||||
self._keyboard.backspace()
|
||||
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
|
||||
# handle enter icon click
|
||||
self._confirm_callback()
|
||||
|
||||
|
||||
class BigDialogButton(BigButton):
|
||||
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
|
||||
super().__init__(text, value, icon)
|
||||
self._description = description
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
dlg = BigDialog(self.text, self._description)
|
||||
gui_app.push_widget(dlg)
|
||||
|
||||
|
||||
class BigConfirmationCircleButton(BigCircleButton):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True,
|
||||
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
|
||||
super().__init__(icon, red, icon_offset)
|
||||
|
||||
def show_confirm_dialog():
|
||||
gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback,
|
||||
exit_on_confirm=exit_on_confirm, red=red))
|
||||
|
||||
self.set_click_callback(show_confirm_dialog)
|
||||
195
iqpilot/selfdrive/ui/mici/widgets/stock_pairing_dialog.py
Normal file
195
iqpilot/selfdrive/ui/mici/widgets/stock_pairing_dialog.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
import os
|
||||
import pyray as rl
|
||||
import qrcode
|
||||
import numpy as np
|
||||
import time
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from iqpilot.common.api.base import BaseApi
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.konn3kt.registration import get_or_create_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import HARDWARE, PC
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
from iqpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
from iqpilot.system.ui.lib.application import FontWeight, gui_app
|
||||
from iqpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from iqpilot.system.ui.lib.multilang import tr
|
||||
|
||||
|
||||
class PairingDialog(NavWidget):
|
||||
"""Dialog for device pairing with QR code."""
|
||||
|
||||
QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._qr_texture: rl.Texture | None = None
|
||||
self._last_qr_generation = float("-inf")
|
||||
|
||||
self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60)
|
||||
self._pair_label = UnifiedLabel(tr("pair with Konn3kt"), font_size=48, font_weight=FontWeight.BOLD, line_height=0.8)
|
||||
|
||||
def _get_pairing_url(self) -> str:
|
||||
dev_pairing = PC and os.getenv("KONN3KT_DEV_PAIRING") == "1"
|
||||
if dev_pairing:
|
||||
try:
|
||||
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
|
||||
except Exception:
|
||||
return "error://dev_identity_setup_failed"
|
||||
|
||||
try:
|
||||
imei1 = HARDWARE.get_imei(0) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei1: {e}")
|
||||
imei1 = ""
|
||||
|
||||
try:
|
||||
imei2 = HARDWARE.get_imei(1) or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get imei2: {e}")
|
||||
imei2 = ""
|
||||
|
||||
try:
|
||||
algorithm, private_key, public_key = BaseApi.get_key_pair()
|
||||
if not private_key or not algorithm:
|
||||
cloudlog.error("No device keys found")
|
||||
return "error://keys_not_found"
|
||||
|
||||
dongle_id = get_or_create_dongle_id(self._params, prefer_readonly=True)
|
||||
|
||||
try:
|
||||
serial = HARDWARE.get_serial() or ""
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to get serial: {e}")
|
||||
serial = ""
|
||||
if not serial:
|
||||
serial = (self._params.get("HardwareSerial") or "") if dev_pairing else ""
|
||||
if not serial:
|
||||
cloudlog.error("No hardware serial found, cannot generate pairing token")
|
||||
return "error://serial_not_found"
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
payload = {
|
||||
'identity': dongle_id,
|
||||
'nbf': now,
|
||||
'iat': now,
|
||||
'imei': imei1,
|
||||
'imei2': imei2,
|
||||
'serial': serial,
|
||||
'public_key': public_key,
|
||||
'register': True,
|
||||
'exp': now + timedelta(hours=1),
|
||||
}
|
||||
|
||||
try:
|
||||
token = jwt.encode(payload, private_key, algorithm=algorithm)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"jwt.encode failed ({e}), retrying with normalized key")
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key_bytes = private_key.encode("utf-8") if isinstance(private_key, str) else private_key
|
||||
try:
|
||||
key_obj = serialization.load_pem_private_key(key_bytes, password=None)
|
||||
except Exception:
|
||||
key_obj = serialization.load_ssh_private_key(key_bytes, password=None)
|
||||
token = jwt.encode(payload, key_obj, algorithm=algorithm)
|
||||
except Exception as e2:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e2}")
|
||||
return "error://token_generation_failed"
|
||||
if isinstance(token, bytes):
|
||||
token = token.decode('utf8')
|
||||
return f"https://konn3kt.com/?pair={token}"
|
||||
except FileNotFoundError as e:
|
||||
cloudlog.error(f"Key files not found: {e}")
|
||||
return "error://keys_not_found"
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Failed to generate pairing token: {e}")
|
||||
return "error://token_generation_failed"
|
||||
|
||||
def _generate_qr_code(self) -> None:
|
||||
try:
|
||||
url = self._get_pairing_url()
|
||||
if url.startswith("error://"):
|
||||
cloudlog.warning(f"Cannot generate QR code: {url}")
|
||||
self._qr_texture = None
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
|
||||
img_array = np.array(pil_img, dtype=np.uint8)
|
||||
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
rl_image = rl.Image()
|
||||
rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
|
||||
rl_image.width = pil_img.width
|
||||
rl_image.height = pil_img.height
|
||||
rl_image.mipmaps = 1
|
||||
rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
|
||||
|
||||
self._qr_texture = rl.load_texture_from_image(rl_image)
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"QR code generation failed: {e}")
|
||||
self._qr_texture = None
|
||||
|
||||
def _check_qr_refresh(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
|
||||
self._generate_qr_code()
|
||||
self._last_qr_generation = current_time
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
if ui_state.prime_state.is_paired() and not self.is_dismissing:
|
||||
self.dismiss()
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._check_qr_refresh()
|
||||
|
||||
self._render_qr_code()
|
||||
|
||||
label_x = self._rect.x + 8 + self._rect.height + 24
|
||||
self._pair_label.set_max_width(int(self._rect.width - label_x))
|
||||
self._pair_label.set_position(label_x, self._rect.y + 16)
|
||||
self._pair_label.render()
|
||||
|
||||
rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
|
||||
0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
|
||||
|
||||
def _render_qr_code(self) -> None:
|
||||
if not self._qr_texture:
|
||||
error_font = gui_app.font(FontWeight.BOLD)
|
||||
rl.draw_text_ex(
|
||||
error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
|
||||
)
|
||||
return
|
||||
|
||||
scale = self._rect.height / self._qr_texture.height
|
||||
pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y))
|
||||
rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
|
||||
|
||||
def __del__(self):
|
||||
if self._qr_texture and self._qr_texture.id != 0:
|
||||
rl.unload_texture(self._qr_texture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("pairing device")
|
||||
pairing = PairingDialog()
|
||||
gui_app.push_widget(pairing)
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
finally:
|
||||
del pairing
|
||||
Reference in New Issue
Block a user