openpilot v0.11.1 release
date: 2026-06-04T09:49:56 master commit: c0ab3550eca2e9daf197c46b7e4b24aa9637cf2e
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from typing import TypeVar
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
|
||||
|
||||
try:
|
||||
from openpilot.selfdrive.ui.ui_state import device
|
||||
except ImportError:
|
||||
class Device:
|
||||
awake = True
|
||||
device = Device()
|
||||
|
||||
W = TypeVar('W', bound='Widget')
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
class DialogResult(IntEnum):
|
||||
CANCEL = 0
|
||||
CONFIRM = 1
|
||||
NO_ACTION = -1
|
||||
|
||||
|
||||
class Widget(abc.ABC):
|
||||
def __init__(self):
|
||||
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
|
||||
self._parent_rect: rl.Rectangle | None = None
|
||||
self._children: list[Widget] = []
|
||||
|
||||
self._enabled: bool | Callable[[], bool] = True
|
||||
self._is_visible: bool | Callable[[], bool] = True
|
||||
|
||||
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
|
||||
# if current mouse/touch down started within the widget's rectangle
|
||||
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
|
||||
self._touch_valid_callback: Callable[[], bool] | None = None
|
||||
self._click_delay: float | None = None # seconds to hold is_pressed after release
|
||||
self._click_release_time: float | None = None
|
||||
self._click_callback: Callable[[], None] | None = None
|
||||
self._multi_touch = False
|
||||
self.__was_awake = True
|
||||
|
||||
@property
|
||||
def rect(self) -> rl.Rectangle:
|
||||
return self._rect
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle) -> None:
|
||||
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
|
||||
self._rect.width != rect.width or self._rect.height != rect.height)
|
||||
self._rect = rect
|
||||
if changed:
|
||||
self._update_layout_rects()
|
||||
|
||||
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
|
||||
"""Can be used like size hint in QT"""
|
||||
self._parent_rect = parent_rect
|
||||
|
||||
@property
|
||||
def is_pressed(self) -> bool:
|
||||
# if actually pressed or holding after release
|
||||
return any(self.__is_pressed) or self._click_release_time is not None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled() if callable(self._enabled) else self._enabled
|
||||
|
||||
def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
|
||||
self._enabled = enabled
|
||||
|
||||
@property
|
||||
def is_visible(self) -> bool:
|
||||
return self._is_visible() if callable(self._is_visible) else self._is_visible
|
||||
|
||||
def set_visible(self, visible: bool | Callable[[], bool]) -> None:
|
||||
self._is_visible = visible
|
||||
|
||||
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
|
||||
"""Set a callback to be called when the widget is clicked."""
|
||||
self._click_callback = click_callback
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
"""Set a callback to determine if the widget can be clicked."""
|
||||
self._touch_valid_callback = touch_callback
|
||||
|
||||
def _touch_valid(self) -> bool:
|
||||
"""Check if the widget can be touched."""
|
||||
return self._touch_valid_callback() if self._touch_valid_callback else True
|
||||
|
||||
def set_position(self, x: float, y: float) -> None:
|
||||
changed = (self._rect.x != x or self._rect.y != y)
|
||||
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
|
||||
if changed:
|
||||
self._update_layout_rects()
|
||||
|
||||
@property
|
||||
def _hit_rect(self) -> rl.Rectangle:
|
||||
# restrict touches to within parent rect if set, useful inside Scroller
|
||||
if self._parent_rect is None:
|
||||
return self._rect
|
||||
return rl.get_collision_rec(self._rect, self._parent_rect)
|
||||
|
||||
def render(self, rect: rl.Rectangle | None = None) -> bool | int | None:
|
||||
if rect is not None:
|
||||
self.set_rect(rect)
|
||||
|
||||
self._update_state()
|
||||
|
||||
if self._click_release_time is not None and rl.get_time() >= self._click_release_time:
|
||||
self._click_release_time = None
|
||||
|
||||
if not self.is_visible:
|
||||
return None
|
||||
|
||||
self._layout()
|
||||
ret = self._render(self._rect)
|
||||
|
||||
if gui_app.show_touches:
|
||||
self._draw_debug_rect()
|
||||
|
||||
# Keep track of whether mouse down started within the widget's rectangle
|
||||
if self.enabled and self.__was_awake:
|
||||
self._process_mouse_events()
|
||||
else:
|
||||
# TODO: ideally we emit release events when going disabled
|
||||
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
|
||||
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
|
||||
|
||||
self.__was_awake = device.awake
|
||||
|
||||
return ret
|
||||
|
||||
def _draw_debug_rect(self) -> None:
|
||||
rl.draw_rectangle_lines(int(self._rect.x), int(self._rect.y),
|
||||
max(int(self._rect.width), 1), max(int(self._rect.height), 1), rl.RED)
|
||||
|
||||
def _process_mouse_events(self) -> None:
|
||||
hit_rect = self._hit_rect
|
||||
touch_valid = self._touch_valid()
|
||||
|
||||
for mouse_event in gui_app.mouse_events:
|
||||
if not self._multi_touch and mouse_event.slot != 0:
|
||||
continue
|
||||
|
||||
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, hit_rect)
|
||||
# Ignores touches/presses that start outside our rect
|
||||
# Allows touch to leave the rect and come back in focus if mouse did not release
|
||||
if mouse_event.left_pressed and touch_valid:
|
||||
if mouse_in_rect:
|
||||
self._handle_mouse_press(mouse_event.pos)
|
||||
self.__is_pressed[mouse_event.slot] = True
|
||||
self.__tracking_is_pressed[mouse_event.slot] = True
|
||||
self._handle_mouse_event(mouse_event)
|
||||
|
||||
# Callback such as scroll panel signifies user is scrolling
|
||||
elif not touch_valid:
|
||||
self.__is_pressed[mouse_event.slot] = False
|
||||
self.__tracking_is_pressed[mouse_event.slot] = False
|
||||
|
||||
elif mouse_event.left_released:
|
||||
self._handle_mouse_event(mouse_event)
|
||||
if self.__is_pressed[mouse_event.slot] and mouse_in_rect:
|
||||
self._handle_mouse_release(mouse_event.pos)
|
||||
self.__is_pressed[mouse_event.slot] = False
|
||||
self.__tracking_is_pressed[mouse_event.slot] = False
|
||||
|
||||
# Mouse/touch is still within our rect
|
||||
elif mouse_in_rect:
|
||||
if self.__tracking_is_pressed[mouse_event.slot]:
|
||||
self.__is_pressed[mouse_event.slot] = True
|
||||
self._handle_mouse_event(mouse_event)
|
||||
|
||||
# Mouse/touch left our rect but may come back into focus later
|
||||
elif not mouse_in_rect:
|
||||
self.__is_pressed[mouse_event.slot] = False
|
||||
self._handle_mouse_event(mouse_event)
|
||||
|
||||
def _layout(self) -> None:
|
||||
"""Optionally lay out child widgets separately. This is called before rendering."""
|
||||
|
||||
def _update_state(self):
|
||||
"""Optionally update the widget's non-layout state. This is called before rendering."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _render(self, rect: rl.Rectangle) -> bool | int | None:
|
||||
"""Render the widget within the given rectangle."""
|
||||
|
||||
def _update_layout_rects(self) -> None:
|
||||
"""Optionally update any layout rects on Widget rect change."""
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos: MousePos) -> None:
|
||||
"""Optionally handle mouse press events."""
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
|
||||
"""Optionally handle mouse release events."""
|
||||
if self._click_delay is not None:
|
||||
self._click_release_time = rl.get_time() + self._click_delay
|
||||
if self._click_callback:
|
||||
self._click_callback()
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
"""Optionally handle mouse events. This is called before rendering."""
|
||||
# Default implementation does nothing, can be overridden by subclasses
|
||||
|
||||
def _child(self, widget: W) -> W:
|
||||
"""
|
||||
Register a widget as a child. Lifecycle events (show/hide) propagate to registered children.
|
||||
- If the widget is pushed onto the nav stack, do NOT register it (gui_app manages its lifecycle).
|
||||
- If the widget is rendered inline in _render(), register it.
|
||||
"""
|
||||
assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}"
|
||||
self._children.append(widget)
|
||||
return widget
|
||||
|
||||
_show_hide_depth = 0
|
||||
|
||||
def show_event(self):
|
||||
"""Called when widget becomes visible. Propagates to registered children."""
|
||||
if DEBUG:
|
||||
print(f"{' ' * Widget._show_hide_depth}show_event: {type(self).__name__}")
|
||||
Widget._show_hide_depth += 1
|
||||
for child in self._children:
|
||||
child.show_event()
|
||||
if DEBUG:
|
||||
Widget._show_hide_depth -= 1
|
||||
|
||||
def hide_event(self):
|
||||
"""Called when widget is hidden. Propagates to registered children."""
|
||||
if DEBUG:
|
||||
print(f"{' ' * Widget._show_hide_depth}hide_event: {type(self).__name__}")
|
||||
Widget._show_hide_depth += 1
|
||||
for child in self._children:
|
||||
child.hide_event()
|
||||
if DEBUG:
|
||||
Widget._show_hide_depth -= 1
|
||||
|
||||
def dismiss(self, callback: Callable[[], None] | None = None):
|
||||
"""Immediately dismiss the widget, firing the callback after."""
|
||||
gui_app.pop_widget()
|
||||
if callback:
|
||||
callback()
|
||||
@@ -0,0 +1,225 @@
|
||||
from collections.abc import Callable
|
||||
from enum import IntEnum
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
|
||||
class ButtonStyle(IntEnum):
|
||||
NORMAL = 0 # Most common, neutral buttons
|
||||
PRIMARY = 1 # For main actions
|
||||
DANGER = 2 # For critical actions, like reboot or delete
|
||||
TRANSPARENT = 3 # For buttons with transparent background and border
|
||||
TRANSPARENT_WHITE_TEXT = 9 # For buttons with transparent background and border and white text
|
||||
TRANSPARENT_WHITE_BORDER = 10 # For buttons with transparent background and white border and text
|
||||
ACTION = 4
|
||||
LIST_ACTION = 5 # For list items with action buttons
|
||||
NO_EFFECT = 6
|
||||
KEYBOARD = 7
|
||||
FORGET_WIFI = 8
|
||||
|
||||
|
||||
ICON_PADDING = 15
|
||||
DEFAULT_BUTTON_FONT_SIZE = 60
|
||||
ACTION_BUTTON_FONT_SIZE = 48
|
||||
|
||||
BUTTON_TEXT_COLOR = {
|
||||
ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255),
|
||||
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
|
||||
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE,
|
||||
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.Color(228, 228, 228, 255),
|
||||
ButtonStyle.ACTION: rl.BLACK,
|
||||
ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255),
|
||||
ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255),
|
||||
ButtonStyle.KEYBOARD: rl.Color(221, 221, 221, 255),
|
||||
ButtonStyle.FORGET_WIFI: rl.Color(51, 51, 51, 255),
|
||||
}
|
||||
|
||||
BUTTON_DISABLED_TEXT_COLORS = {
|
||||
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE,
|
||||
}
|
||||
|
||||
BUTTON_BACKGROUND_COLORS = {
|
||||
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
|
||||
ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255),
|
||||
ButtonStyle.DANGER: rl.Color(226, 44, 44, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
|
||||
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLACK,
|
||||
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
|
||||
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
|
||||
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
|
||||
ButtonStyle.KEYBOARD: rl.Color(68, 68, 68, 255),
|
||||
ButtonStyle.FORGET_WIFI: rl.Color(189, 189, 189, 255),
|
||||
}
|
||||
|
||||
BUTTON_PRESSED_BACKGROUND_COLORS = {
|
||||
ButtonStyle.NORMAL: rl.Color(74, 74, 74, 255),
|
||||
ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255),
|
||||
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
|
||||
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLANK,
|
||||
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
|
||||
ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74),
|
||||
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
|
||||
ButtonStyle.KEYBOARD: rl.Color(51, 51, 51, 255),
|
||||
ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255),
|
||||
}
|
||||
|
||||
BUTTON_DISABLED_BACKGROUND_COLORS = {
|
||||
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
|
||||
}
|
||||
|
||||
|
||||
class Button(Widget):
|
||||
def __init__(self,
|
||||
text: str | Callable[[], str],
|
||||
click_callback: Callable[[], None] | None = None,
|
||||
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
|
||||
font_weight: FontWeight = FontWeight.MEDIUM,
|
||||
button_style: ButtonStyle = ButtonStyle.NORMAL,
|
||||
border_radius: int = 10,
|
||||
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
text_padding: int = 20,
|
||||
icon=None,
|
||||
elide_right: bool = False,
|
||||
multi_touch: bool = False,
|
||||
):
|
||||
|
||||
super().__init__()
|
||||
self._button_style = button_style
|
||||
self._border_radius = border_radius
|
||||
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
|
||||
|
||||
self._label = Label(text, font_size, font_weight, text_alignment, text_padding=text_padding,
|
||||
text_color=BUTTON_TEXT_COLOR[self._button_style], icon=icon, elide_right=elide_right)
|
||||
|
||||
self._click_callback = click_callback
|
||||
self._multi_touch = multi_touch
|
||||
|
||||
def set_text(self, text):
|
||||
self._label.set_text(text)
|
||||
|
||||
def set_button_style(self, button_style: ButtonStyle):
|
||||
self._button_style = button_style
|
||||
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
|
||||
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
|
||||
|
||||
def _update_state(self):
|
||||
if self.enabled:
|
||||
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
|
||||
if self.is_pressed:
|
||||
self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
|
||||
else:
|
||||
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
|
||||
elif self._button_style != ButtonStyle.NO_EFFECT:
|
||||
self._background_color = BUTTON_DISABLED_BACKGROUND_COLORS.get(self._button_style, rl.Color(51, 51, 51, 255))
|
||||
self._label.set_text_color(BUTTON_DISABLED_TEXT_COLORS.get(self._button_style, rl.Color(228, 228, 228, 51)))
|
||||
|
||||
def _render(self, _):
|
||||
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
|
||||
if self._button_style == ButtonStyle.TRANSPARENT_WHITE_BORDER:
|
||||
rl.draw_rectangle_rounded(self._rect, roundness, 10, rl.BLACK)
|
||||
rl.draw_rectangle_rounded_lines_ex(self._rect, roundness, 10, 2, rl.WHITE)
|
||||
else:
|
||||
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
|
||||
self._label.render(self._rect)
|
||||
|
||||
|
||||
class ButtonRadio(Button):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
icon,
|
||||
click_callback: Callable[[], None] | None = None,
|
||||
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
|
||||
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
border_radius: int = 10,
|
||||
text_padding: int = 20,
|
||||
):
|
||||
|
||||
super().__init__(text, click_callback=click_callback, font_size=font_size,
|
||||
border_radius=border_radius, text_padding=text_padding,
|
||||
text_alignment=text_alignment)
|
||||
self._text_padding = text_padding
|
||||
self._icon = icon
|
||||
self.selected = False
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
self.selected = not self.selected
|
||||
|
||||
def _update_state(self):
|
||||
if self.selected:
|
||||
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.PRIMARY]
|
||||
else:
|
||||
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.NORMAL]
|
||||
|
||||
def _render(self, _):
|
||||
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
|
||||
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
|
||||
self._label.render(self._rect)
|
||||
|
||||
if self._icon and self.selected:
|
||||
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
|
||||
icon_x = self._rect.x + self._rect.width - self._icon.width - self._text_padding - ICON_PADDING
|
||||
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE if self.enabled else rl.Color(255, 255, 255, 100))
|
||||
|
||||
|
||||
class IconButton(Widget):
|
||||
def __init__(self, texture: rl.Texture):
|
||||
super().__init__()
|
||||
self._texture = texture
|
||||
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height))
|
||||
|
||||
def set_opacity(self, opacity: float, smooth: bool = False):
|
||||
if smooth:
|
||||
self._opacity_filter.update(opacity)
|
||||
else:
|
||||
self._opacity_filter.x = opacity
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE
|
||||
if not self.enabled:
|
||||
color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
|
||||
draw_x = rect.x + (rect.width - self._texture.width) / 2
|
||||
draw_y = rect.y + (rect.height - self._texture.height) / 2
|
||||
rl.draw_texture_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, color)
|
||||
|
||||
|
||||
class SmallCircleIconButton(Widget):
|
||||
def __init__(self, icon_txt: rl.Texture):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, 100, 100))
|
||||
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._icon_bg_txt = gui_app.texture("icons_mici/setup/small_button.png", 100, 100)
|
||||
self._icon_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_button_pressed.png", 100, 100)
|
||||
self._icon_bg_disabled_txt = gui_app.texture("icons_mici/setup/small_button_disabled.png", 100, 100)
|
||||
self._icon_txt = icon_txt
|
||||
|
||||
def set_opacity(self, opacity: float, smooth: bool = False):
|
||||
if smooth:
|
||||
self._opacity_filter.update(opacity)
|
||||
else:
|
||||
self._opacity_filter.x = opacity
|
||||
|
||||
def _render(self, _):
|
||||
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
|
||||
if not self.enabled:
|
||||
bg_txt = self._icon_bg_disabled_txt
|
||||
icon_white = rl.Color(255, 255, 255, int(white.a * 0.35))
|
||||
else:
|
||||
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
|
||||
icon_white = white
|
||||
|
||||
rl.draw_texture_ex(bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, white)
|
||||
icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2
|
||||
icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2
|
||||
rl.draw_texture_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white)
|
||||
@@ -0,0 +1,94 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import DialogResult
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle, Button
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
OUTER_MARGIN = 200
|
||||
RICH_OUTER_MARGIN = 100
|
||||
BUTTON_HEIGHT = 160
|
||||
MARGIN = 50
|
||||
TEXT_PADDING = 10
|
||||
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
|
||||
|
||||
|
||||
class ConfirmDialog(Widget):
|
||||
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False, callback: Callable[[DialogResult], None] | None = None):
|
||||
super().__init__()
|
||||
if cancel_text is None:
|
||||
cancel_text = tr("Cancel")
|
||||
self._label = Label(text, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255))
|
||||
self._html_renderer = HtmlRenderer(text=text, text_size={ElementType.P: 50}, center_text=True)
|
||||
self._cancel_button = Button(cancel_text, self._cancel_button_callback)
|
||||
self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
|
||||
self._rich = rich
|
||||
self._callback = callback
|
||||
self._cancel_text = cancel_text
|
||||
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
|
||||
|
||||
def set_text(self, text):
|
||||
if not self._rich:
|
||||
self._label.set_text(text)
|
||||
else:
|
||||
self._html_renderer.parse_html_content(text)
|
||||
|
||||
def _cancel_button_callback(self):
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CANCEL)
|
||||
|
||||
def _confirm_button_callback(self):
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CONFIRM)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
|
||||
dialog_y = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
|
||||
dialog_width = gui_app.width - 2 * dialog_x
|
||||
dialog_height = gui_app.height - 2 * dialog_y
|
||||
dialog_rect = rl.Rectangle(dialog_x, dialog_y, dialog_width, dialog_height)
|
||||
|
||||
bottom = dialog_rect.y + dialog_rect.height
|
||||
button_width = (dialog_rect.width - 3 * MARGIN) // 2
|
||||
cancel_button_x = dialog_rect.x + MARGIN
|
||||
confirm_button_x = dialog_rect.x + dialog_rect.width - button_width - MARGIN
|
||||
button_y = bottom - BUTTON_HEIGHT - MARGIN
|
||||
cancel_button = rl.Rectangle(cancel_button_x, button_y, button_width, BUTTON_HEIGHT)
|
||||
confirm_button = rl.Rectangle(confirm_button_x, button_y, button_width, BUTTON_HEIGHT)
|
||||
|
||||
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
|
||||
|
||||
text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + TEXT_PADDING,
|
||||
dialog_rect.width - 2 * MARGIN, dialog_rect.height - BUTTON_HEIGHT - MARGIN - TEXT_PADDING * 2)
|
||||
if not self._rich:
|
||||
self._label.render(text_rect)
|
||||
else:
|
||||
html_rect = rl.Rectangle(text_rect.x, text_rect.y, text_rect.width,
|
||||
self._html_renderer.get_total_height(int(text_rect.width)))
|
||||
self._html_renderer.set_rect(html_rect)
|
||||
self._scroller.render(text_rect)
|
||||
|
||||
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
||||
self._confirm_button_callback()
|
||||
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
||||
self._cancel_button_callback()
|
||||
|
||||
if self._cancel_text:
|
||||
self._confirm_button.render(confirm_button)
|
||||
self._cancel_button.render(cancel_button)
|
||||
else:
|
||||
full_button_width = dialog_rect.width - 2 * MARGIN
|
||||
full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT)
|
||||
self._confirm_button.render(full_confirm_button)
|
||||
|
||||
|
||||
def alert_dialog(message: str, button_text: str | None = None):
|
||||
if button_text is None:
|
||||
button_text = tr("OK")
|
||||
return ConfirmDialog(message, button_text, cancel_text="")
|
||||
@@ -0,0 +1,290 @@
|
||||
import re
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
LIST_INDENT_PX = 40
|
||||
|
||||
|
||||
class ElementType(Enum):
|
||||
H1 = "h1"
|
||||
H2 = "h2"
|
||||
H3 = "h3"
|
||||
H4 = "h4"
|
||||
H5 = "h5"
|
||||
H6 = "h6"
|
||||
P = "p"
|
||||
B = "b"
|
||||
UL = "ul"
|
||||
LI = "li"
|
||||
BR = "br"
|
||||
|
||||
|
||||
TAG_NAMES = '|'.join([t.value for t in ElementType])
|
||||
START_TAG_RE = re.compile(f'<({TAG_NAMES})>')
|
||||
END_TAG_RE = re.compile(f'</({TAG_NAMES})>')
|
||||
COMMENT_RE = re.compile(r'<!--.*?-->', flags=re.DOTALL)
|
||||
DOCTYPE_RE = re.compile(r'<!DOCTYPE[^>]*>')
|
||||
HTML_BODY_TAGS_RE = re.compile(r'</?(?:html|head|body)[^>]*>')
|
||||
TOKEN_RE = re.compile(r'</[^>]+>|<[^>]+>|[^<\s]+')
|
||||
|
||||
|
||||
def is_tag(token: str) -> tuple[bool, bool, ElementType | None]:
|
||||
supported_tag = bool(START_TAG_RE.fullmatch(token))
|
||||
supported_end_tag = bool(END_TAG_RE.fullmatch(token))
|
||||
tag = ElementType(token[1:-1].strip('/')) if supported_tag or supported_end_tag else None
|
||||
return supported_tag, supported_end_tag, tag
|
||||
|
||||
|
||||
@dataclass
|
||||
class HtmlElement:
|
||||
type: ElementType
|
||||
content: str
|
||||
font_size: int
|
||||
font_weight: FontWeight
|
||||
margin_top: int
|
||||
margin_bottom: int
|
||||
line_height: float = 0.9 # matches Qt visually, unsure why not default 1.2
|
||||
indent_level: int = 0
|
||||
|
||||
|
||||
class HtmlRenderer(Widget):
|
||||
def __init__(self, file_path: str | None = None, text: str | None = None,
|
||||
text_size: dict | None = None, text_color: rl.Color = rl.WHITE, center_text: bool = False):
|
||||
super().__init__()
|
||||
self._text_color = text_color
|
||||
self._center_text = center_text
|
||||
self._normal_font = gui_app.font(FontWeight.NORMAL)
|
||||
self._bold_font = gui_app.font(FontWeight.BOLD)
|
||||
self._indent_level = 0
|
||||
|
||||
if text_size is None:
|
||||
text_size = {}
|
||||
|
||||
self._cached_height: float | None = None
|
||||
self._cached_width: int = -1
|
||||
|
||||
# Base paragraph size (Qt stylesheet default is 48px in offroad alerts)
|
||||
base_p_size = int(text_size.get(ElementType.P, 48))
|
||||
|
||||
# Untagged text defaults to <p>
|
||||
self.styles: dict[ElementType, dict[str, Any]] = {
|
||||
ElementType.H1: {"size": round(base_p_size * 2), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 16},
|
||||
ElementType.H2: {"size": round(base_p_size * 1.50), "weight": FontWeight.BOLD, "margin_top": 24, "margin_bottom": 12},
|
||||
ElementType.H3: {"size": round(base_p_size * 1.17), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 10},
|
||||
ElementType.H4: {"size": round(base_p_size * 1.00), "weight": FontWeight.BOLD, "margin_top": 16, "margin_bottom": 8},
|
||||
ElementType.H5: {"size": round(base_p_size * 0.83), "weight": FontWeight.BOLD, "margin_top": 12, "margin_bottom": 6},
|
||||
ElementType.H6: {"size": round(base_p_size * 0.67), "weight": FontWeight.BOLD, "margin_top": 10, "margin_bottom": 4},
|
||||
ElementType.P: {"size": base_p_size, "weight": FontWeight.NORMAL, "margin_top": 8, "margin_bottom": 12},
|
||||
ElementType.B: {"size": base_p_size, "weight": FontWeight.BOLD, "margin_top": 8, "margin_bottom": 12},
|
||||
ElementType.LI: {"size": base_p_size, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 6, "margin_bottom": 6},
|
||||
ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "margin_top": 0, "margin_bottom": 12},
|
||||
}
|
||||
|
||||
self.elements: list[HtmlElement] = []
|
||||
if file_path is not None:
|
||||
self.parse_html_file(file_path)
|
||||
elif text is not None:
|
||||
self.parse_html_content(text)
|
||||
else:
|
||||
raise ValueError("Either file_path or text must be provided")
|
||||
|
||||
def parse_html_file(self, file_path: str) -> None:
|
||||
with open(file_path, encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
self.parse_html_content(content)
|
||||
|
||||
def parse_html_content(self, html_content: str) -> None:
|
||||
self.elements.clear()
|
||||
self._cached_height = None
|
||||
self._cached_width = -1
|
||||
|
||||
# Remove HTML comments
|
||||
html_content = COMMENT_RE.sub('', html_content)
|
||||
|
||||
# Remove DOCTYPE, html, head, body tags but keep their content
|
||||
html_content = DOCTYPE_RE.sub('', html_content)
|
||||
html_content = HTML_BODY_TAGS_RE.sub('', html_content)
|
||||
|
||||
# Parse HTML
|
||||
tokens = TOKEN_RE.findall(html_content)
|
||||
|
||||
def close_tag():
|
||||
nonlocal current_content
|
||||
nonlocal current_tag
|
||||
|
||||
# If no tag is set, default to paragraph so we don't lose text
|
||||
if current_tag is None:
|
||||
current_tag = ElementType.P
|
||||
|
||||
text = ' '.join(current_content).strip()
|
||||
current_content = []
|
||||
if text:
|
||||
if current_tag == ElementType.LI:
|
||||
text = '• ' + text
|
||||
self._add_element(current_tag, text)
|
||||
|
||||
current_content: list[str] = []
|
||||
current_tag: ElementType | None = None
|
||||
for token in tokens:
|
||||
is_start_tag, is_end_tag, tag = is_tag(token)
|
||||
if tag is not None:
|
||||
if tag == ElementType.BR:
|
||||
# Close current tag and add a line break
|
||||
close_tag()
|
||||
self._add_element(ElementType.BR, "")
|
||||
|
||||
elif is_start_tag or is_end_tag:
|
||||
# Always add content regardless of opening or closing tag
|
||||
close_tag()
|
||||
|
||||
if is_start_tag:
|
||||
current_tag = tag
|
||||
else:
|
||||
current_tag = None
|
||||
|
||||
# increment after we add the content for the current tag
|
||||
if tag == ElementType.UL:
|
||||
self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1)
|
||||
|
||||
else:
|
||||
current_content.append(token)
|
||||
|
||||
if current_content:
|
||||
close_tag()
|
||||
|
||||
def _add_element(self, element_type: ElementType, content: str) -> None:
|
||||
style = self.styles[element_type]
|
||||
|
||||
element = HtmlElement(
|
||||
type=element_type,
|
||||
content=content,
|
||||
font_size=style["size"],
|
||||
font_weight=style["weight"],
|
||||
margin_top=style["margin_top"],
|
||||
margin_bottom=style["margin_bottom"],
|
||||
indent_level=self._indent_level,
|
||||
)
|
||||
|
||||
self.elements.append(element)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
# TODO: speed up by removing duplicate calculations across renders
|
||||
current_y = rect.y
|
||||
padding = 20
|
||||
content_width = rect.width - (padding * 2)
|
||||
|
||||
for element in self.elements:
|
||||
if element.type == ElementType.BR:
|
||||
current_y += element.margin_bottom
|
||||
continue
|
||||
|
||||
current_y += element.margin_top
|
||||
if current_y > rect.y + rect.height:
|
||||
break
|
||||
|
||||
if element.content:
|
||||
font = self._get_font(element.font_weight)
|
||||
wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width))
|
||||
|
||||
for line in wrapped_lines:
|
||||
# Use FONT_SCALE from wrapped raylib text functions to match what is drawn
|
||||
if current_y < rect.y - element.font_size * FONT_SCALE:
|
||||
current_y += element.font_size * FONT_SCALE * element.line_height
|
||||
continue
|
||||
|
||||
if current_y > rect.y + rect.height:
|
||||
break
|
||||
|
||||
if self._center_text:
|
||||
text_width = measure_text_cached(font, line, element.font_size).x
|
||||
text_x = rect.x + (rect.width - text_width) / 2
|
||||
else: # left align
|
||||
text_x = rect.x + (max(element.indent_level - 1, 0) * LIST_INDENT_PX)
|
||||
|
||||
rl.draw_text_ex(font, line, rl.Vector2(text_x + padding, current_y), element.font_size, 0, self._text_color)
|
||||
|
||||
current_y += element.font_size * FONT_SCALE * element.line_height
|
||||
|
||||
# Apply bottom margin
|
||||
current_y += element.margin_bottom
|
||||
|
||||
return current_y - rect.y
|
||||
|
||||
def get_total_height(self, content_width: int) -> float:
|
||||
if self._cached_height is not None and self._cached_width == content_width:
|
||||
return self._cached_height
|
||||
|
||||
total_height = 0.0
|
||||
padding = 20
|
||||
usable_width = content_width - (padding * 2)
|
||||
|
||||
for element in self.elements:
|
||||
if element.type == ElementType.BR:
|
||||
total_height += element.margin_bottom
|
||||
continue
|
||||
|
||||
total_height += element.margin_top
|
||||
|
||||
if element.content:
|
||||
font = self._get_font(element.font_weight)
|
||||
wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width))
|
||||
|
||||
for _ in wrapped_lines:
|
||||
total_height += element.font_size * FONT_SCALE * element.line_height
|
||||
|
||||
total_height += element.margin_bottom
|
||||
|
||||
# Store result in cache
|
||||
self._cached_height = total_height
|
||||
self._cached_width = content_width
|
||||
|
||||
return total_height
|
||||
|
||||
def _get_font(self, weight: FontWeight):
|
||||
if weight == FontWeight.BOLD:
|
||||
return self._bold_font
|
||||
return self._normal_font
|
||||
|
||||
|
||||
class HtmlModal(Widget):
|
||||
def __init__(self, file_path: str | None = None, text: str | None = None):
|
||||
super().__init__()
|
||||
self._content = HtmlRenderer(file_path=file_path, text=text)
|
||||
self._scroll_panel = GuiScrollPanel()
|
||||
self._ok_button = Button(tr("OK"), click_callback=gui_app.pop_widget, button_style=ButtonStyle.PRIMARY)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
margin = 50
|
||||
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2))
|
||||
|
||||
button_height = 160
|
||||
button_spacing = 20
|
||||
scrollable_height = content_rect.height - button_height - button_spacing
|
||||
|
||||
scrollable_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, scrollable_height)
|
||||
|
||||
total_height = self._content.get_total_height(int(scrollable_rect.width))
|
||||
scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height)
|
||||
scroll_offset = self._scroll_panel.update(scrollable_rect, scroll_content_rect)
|
||||
scroll_content_rect.y += scroll_offset
|
||||
|
||||
rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height))
|
||||
self._content.render(scroll_content_rect)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
button_width = (rect.width - 3 * 50) // 3
|
||||
button_x = content_rect.x + content_rect.width - button_width
|
||||
button_y = content_rect.y + content_rect.height - button_height
|
||||
button_rect = rl.Rectangle(button_x, button_y, button_width, button_height)
|
||||
self._ok_button.render(button_rect)
|
||||
|
||||
return -1
|
||||
@@ -0,0 +1,16 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
|
||||
class IconWidget(Widget):
|
||||
def __init__(self, image_path: str, size: tuple[int, int], opacity: float = 1.0):
|
||||
super().__init__()
|
||||
self._texture = gui_app.texture(image_path, size[0], size[1])
|
||||
self._opacity = opacity
|
||||
self.set_rect(rl.Rectangle(0, 0, float(size[0]), float(size[1])))
|
||||
self.set_enabled(False)
|
||||
|
||||
def _render(self, _) -> None:
|
||||
color = rl.Color(255, 255, 255, int(self._opacity * 255))
|
||||
rl.draw_texture_ex(self._texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, color)
|
||||
@@ -0,0 +1,228 @@
|
||||
import pyray as rl
|
||||
import time
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, FONT_SCALE
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
PASSWORD_MASK_CHAR = "•"
|
||||
PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking
|
||||
|
||||
|
||||
class InputBox(Widget):
|
||||
def __init__(self, max_text_size=255, password_mode=False):
|
||||
super().__init__()
|
||||
self._max_text_size = max_text_size
|
||||
self._input_text = ""
|
||||
self._cursor_position = 0
|
||||
self._password_mode = password_mode
|
||||
self._blink_counter = 0
|
||||
self._show_cursor = False
|
||||
self._last_key_pressed = 0
|
||||
self._key_press_time = 0
|
||||
self._repeat_delay = 30
|
||||
self._repeat_rate = 4
|
||||
self._text_offset = 0
|
||||
self._visible_width = 0
|
||||
self._last_char_time = 0 # Track when last character was added
|
||||
self._masked_length = 0 # How many characters are currently masked
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._input_text
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
self._input_text = value[: self._max_text_size]
|
||||
self._cursor_position = len(self._input_text)
|
||||
self._update_text_offset()
|
||||
|
||||
def set_password_mode(self, password_mode):
|
||||
self._password_mode = password_mode
|
||||
|
||||
def clear(self):
|
||||
self._input_text = ''
|
||||
self._cursor_position = 0
|
||||
self._text_offset = 0
|
||||
|
||||
def set_cursor_position(self, position):
|
||||
"""Set the cursor position and reset the blink counter."""
|
||||
if 0 <= position <= len(self._input_text):
|
||||
self._cursor_position = position
|
||||
self._blink_counter = 0
|
||||
self._show_cursor = True
|
||||
self._update_text_offset()
|
||||
|
||||
def _update_text_offset(self):
|
||||
"""Ensure the cursor is visible by adjusting text offset."""
|
||||
if self._visible_width == 0:
|
||||
return
|
||||
|
||||
font = gui_app.font()
|
||||
display_text = self._get_display_text()
|
||||
padding = 10
|
||||
|
||||
if self._cursor_position > 0:
|
||||
cursor_x = measure_text_cached(font, display_text[: self._cursor_position], self._font_size).x
|
||||
else:
|
||||
cursor_x = 0
|
||||
|
||||
visible_width = self._visible_width - (padding * 2)
|
||||
|
||||
# Adjust offset if cursor would be outside visible area
|
||||
if cursor_x < self._text_offset:
|
||||
self._text_offset = max(0, cursor_x - padding)
|
||||
elif cursor_x > self._text_offset + visible_width:
|
||||
self._text_offset = cursor_x - visible_width + padding
|
||||
|
||||
def add_char_at_cursor(self, char):
|
||||
"""Add a character at the current cursor position."""
|
||||
if len(self._input_text) < self._max_text_size:
|
||||
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position:]
|
||||
self.set_cursor_position(self._cursor_position + 1)
|
||||
|
||||
if self._password_mode:
|
||||
self._last_char_time = time.monotonic()
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_char_before_cursor(self):
|
||||
"""Delete the character before the cursor position (backspace)."""
|
||||
if self._cursor_position > 0:
|
||||
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position:]
|
||||
self.set_cursor_position(self._cursor_position - 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_char_at_cursor(self):
|
||||
"""Delete the character at the cursor position (delete)."""
|
||||
if self._cursor_position < len(self._input_text):
|
||||
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1:]
|
||||
self.set_cursor_position(self._cursor_position)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
|
||||
# Store dimensions for text offset calculations
|
||||
self._visible_width = rect.width
|
||||
self._font_size = font_size
|
||||
|
||||
# Draw input box
|
||||
rl.draw_rectangle_rec(rect, color)
|
||||
|
||||
# Process keyboard input
|
||||
self._handle_keyboard_input()
|
||||
|
||||
# Update cursor blink
|
||||
self._blink_counter += 1
|
||||
if self._blink_counter >= 30:
|
||||
self._show_cursor = not self._show_cursor
|
||||
self._blink_counter = 0
|
||||
|
||||
# Display text
|
||||
font = gui_app.font()
|
||||
display_text = self._get_display_text()
|
||||
padding = 10
|
||||
|
||||
# Clip text within input box bounds
|
||||
buffer = 2
|
||||
rl.begin_scissor_mode(int(rect.x + padding - buffer), int(rect.y), int(rect.width - padding * 2 + buffer * 2), int(rect.height))
|
||||
rl.draw_text_ex(
|
||||
font,
|
||||
display_text,
|
||||
rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size * FONT_SCALE / 2)),
|
||||
font_size,
|
||||
0,
|
||||
text_color,
|
||||
)
|
||||
|
||||
# Draw cursor
|
||||
if self._show_cursor:
|
||||
cursor_x = rect.x + padding
|
||||
if len(display_text) > 0 and self._cursor_position > 0:
|
||||
cursor_x += measure_text_cached(font, display_text[: self._cursor_position], font_size).x
|
||||
|
||||
# Apply text offset to cursor position
|
||||
cursor_x -= self._text_offset
|
||||
|
||||
cursor_height = font_size * FONT_SCALE + 4
|
||||
cursor_y = rect.y + rect.height / 2 - cursor_height / 2
|
||||
rl.draw_line(int(cursor_x), int(cursor_y), int(cursor_x), int(cursor_y + cursor_height), rl.WHITE)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _get_display_text(self):
|
||||
"""Get text to display, applying password masking with delay if needed."""
|
||||
if not self._password_mode:
|
||||
return self._input_text
|
||||
|
||||
# Show character at last edited position if within delay window
|
||||
masked_text = PASSWORD_MASK_CHAR * len(self._input_text)
|
||||
recent_edit = time.monotonic() - self._last_char_time < PASSWORD_MASK_DELAY
|
||||
if recent_edit and self._input_text:
|
||||
last_pos = max(0, self._cursor_position - 1)
|
||||
if last_pos < len(self._input_text):
|
||||
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1:]
|
||||
|
||||
return masked_text
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
# Calculate cursor position from click
|
||||
if len(self._input_text) > 0:
|
||||
font = gui_app.font()
|
||||
display_text = self._get_display_text()
|
||||
|
||||
# Find the closest character position to the click
|
||||
relative_x = mouse_pos.x - (self._rect.x + 10) + self._text_offset
|
||||
best_pos = 0
|
||||
min_distance = float('inf')
|
||||
|
||||
for i in range(len(self._input_text) + 1):
|
||||
char_width = measure_text_cached(font, display_text[:i], self._font_size).x
|
||||
distance = abs(relative_x - char_width)
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
best_pos = i
|
||||
|
||||
self.set_cursor_position(best_pos)
|
||||
else:
|
||||
self.set_cursor_position(0)
|
||||
|
||||
def _handle_keyboard_input(self):
|
||||
# Handle navigation keys
|
||||
key = rl.get_key_pressed()
|
||||
if key != 0:
|
||||
self._process_key(key)
|
||||
if key in (rl.KEY_LEFT, rl.KEY_RIGHT, rl.KEY_BACKSPACE, rl.KEY_DELETE):
|
||||
self._last_key_pressed = key
|
||||
self._key_press_time = 0
|
||||
|
||||
# Handle repeats for held keys
|
||||
elif self._last_key_pressed != 0:
|
||||
if rl.is_key_down(self._last_key_pressed):
|
||||
self._key_press_time += 1
|
||||
if self._key_press_time > self._repeat_delay and self._key_press_time % self._repeat_rate == 0:
|
||||
self._process_key(self._last_key_pressed)
|
||||
else:
|
||||
self._last_key_pressed = 0
|
||||
|
||||
# Handle text input
|
||||
char = rl.get_char_pressed()
|
||||
if char != 0 and char >= 32: # Filter out control characters
|
||||
self.add_char_at_cursor(chr(char))
|
||||
|
||||
def _process_key(self, key):
|
||||
if key == rl.KEY_LEFT:
|
||||
if self._cursor_position > 0:
|
||||
self.set_cursor_position(self._cursor_position - 1)
|
||||
elif key == rl.KEY_RIGHT:
|
||||
if self._cursor_position < len(self._input_text):
|
||||
self.set_cursor_position(self._cursor_position + 1)
|
||||
elif key == rl.KEY_BACKSPACE:
|
||||
self.delete_char_before_cursor()
|
||||
elif key == rl.KEY_DELETE:
|
||||
self.delete_char_at_cursor()
|
||||
elif key == rl.KEY_HOME:
|
||||
self.set_cursor_position(0)
|
||||
elif key == rl.KEY_END:
|
||||
self.set_cursor_position(len(self._input_text))
|
||||
@@ -0,0 +1,282 @@
|
||||
from functools import partial
|
||||
import time
|
||||
from typing import Literal
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import DialogResult, Widget
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle, Button
|
||||
from openpilot.system.ui.widgets.inputbox import InputBox
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
|
||||
KEY_FONT_SIZE = 96
|
||||
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
|
||||
DELETE_REPEAT_DELAY = 0.5
|
||||
DELETE_REPEAT_INTERVAL = 0.07
|
||||
|
||||
# Constants for special keys
|
||||
CONTENT_MARGIN = 50
|
||||
BACKSPACE_KEY = "<-"
|
||||
ENTER_KEY = "->"
|
||||
SPACE_KEY = " "
|
||||
SHIFT_INACTIVE_KEY = "SHIFT_OFF"
|
||||
SHIFT_ACTIVE_KEY = "SHIFT_ON"
|
||||
CAPS_LOCK_KEY = "CAPS"
|
||||
NUMERIC_KEY = "123"
|
||||
SYMBOL_KEY = "#+="
|
||||
ABC_KEY = "ABC"
|
||||
|
||||
# Define keyboard layouts as a dictionary for easier access
|
||||
KEYBOARD_LAYOUTS = {
|
||||
"lowercase": [
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
|
||||
[SHIFT_INACTIVE_KEY, "z", "x", "c", "v", "b", "n", "m", BACKSPACE_KEY],
|
||||
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
|
||||
],
|
||||
"uppercase": [
|
||||
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
|
||||
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
|
||||
[SHIFT_ACTIVE_KEY, "Z", "X", "C", "V", "B", "N", "M", BACKSPACE_KEY],
|
||||
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
|
||||
],
|
||||
"numbers": [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
|
||||
[SYMBOL_KEY, "_", ",", "?", "!", "`", BACKSPACE_KEY],
|
||||
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
|
||||
],
|
||||
"specials": [
|
||||
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
|
||||
["_", "\\", "|", "~", "<", ">", "€", "£", "¥", "•"],
|
||||
[NUMERIC_KEY, "-", ",", "?", "!", "'", BACKSPACE_KEY],
|
||||
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Keyboard(Widget):
|
||||
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False,
|
||||
callback: Callable[[DialogResult], None] | None = None):
|
||||
super().__init__()
|
||||
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
|
||||
self._caps_lock = False
|
||||
self._last_shift_press_time = 0
|
||||
self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
|
||||
self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
|
||||
|
||||
self._max_text_size = max_text_size
|
||||
self._min_text_size = min_text_size
|
||||
self._input_box = InputBox(max_text_size)
|
||||
self._password_mode = password_mode
|
||||
self._show_password_toggle = show_password_toggle
|
||||
self._callback = callback
|
||||
|
||||
# Backspace key repeat tracking
|
||||
self._backspace_pressed: bool = False
|
||||
self._backspace_press_time: float = 0.0
|
||||
self._backspace_last_repeat: float = 0.0
|
||||
|
||||
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
|
||||
|
||||
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
|
||||
|
||||
self._eye_open_texture = gui_app.texture("icons/eye_open.png", 81, 54)
|
||||
self._eye_closed_texture = gui_app.texture("icons/eye_closed.png", 81, 54)
|
||||
self._key_icons = {
|
||||
BACKSPACE_KEY: gui_app.texture("icons/backspace.png", 80, 80),
|
||||
SHIFT_INACTIVE_KEY: gui_app.texture("icons/shift.png", 80, 80),
|
||||
SHIFT_ACTIVE_KEY: gui_app.texture("icons/shift-fill.png", 80, 80),
|
||||
CAPS_LOCK_KEY: gui_app.texture("icons/capslock-fill.png", 80, 80),
|
||||
ENTER_KEY: gui_app.texture("icons/arrow-right.png", 80, 80),
|
||||
}
|
||||
|
||||
self._all_keys = {}
|
||||
for l in KEYBOARD_LAYOUTS:
|
||||
for _, keys in enumerate(KEYBOARD_LAYOUTS[l]):
|
||||
for _, key in enumerate(keys):
|
||||
if key in self._key_icons:
|
||||
texture = self._key_icons[key]
|
||||
self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture,
|
||||
button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True)
|
||||
else:
|
||||
self._all_keys[key] = Button(key, partial(self._key_callback, key), button_style=ButtonStyle.KEYBOARD, font_size=85, multi_touch=True)
|
||||
self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY],
|
||||
button_style=ButtonStyle.KEYBOARD, multi_touch=True)
|
||||
|
||||
def set_text(self, text: str):
|
||||
self._input_box.text = text
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._input_box.text
|
||||
|
||||
def clear(self):
|
||||
self._layout_name = "lowercase"
|
||||
self._caps_lock = False
|
||||
self._input_box.clear()
|
||||
self._backspace_pressed = False
|
||||
|
||||
def set_title(self, title: str, sub_title: str = ""):
|
||||
self._title.set_text(title)
|
||||
self._sub_title.set_text(sub_title)
|
||||
|
||||
def set_callback(self, callback: Callable[[DialogResult], None] | None):
|
||||
self._callback = callback
|
||||
|
||||
def _eye_button_callback(self):
|
||||
self._password_mode = not self._password_mode
|
||||
|
||||
def _cancel_button_callback(self):
|
||||
self.clear()
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CANCEL)
|
||||
|
||||
def _key_callback(self, k):
|
||||
if k == ENTER_KEY:
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CONFIRM)
|
||||
else:
|
||||
self.handle_key_press(k)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN)
|
||||
self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95))
|
||||
self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))
|
||||
self._cancel_button.render(rl.Rectangle(rect.x + rect.width - 386, rect.y, 386, 125))
|
||||
|
||||
# Draw input box and password toggle
|
||||
input_margin = 25
|
||||
input_box_rect = rl.Rectangle(rect.x + input_margin, rect.y + 160, rect.width - input_margin, 100)
|
||||
self._render_input_area(input_box_rect)
|
||||
|
||||
# Process backspace key repeat if it's held down
|
||||
if not self._all_keys[BACKSPACE_KEY].is_pressed:
|
||||
self._backspace_pressed = False
|
||||
|
||||
if self._backspace_pressed:
|
||||
current_time = time.monotonic()
|
||||
time_since_press = current_time - self._backspace_press_time
|
||||
|
||||
# After initial delay, start repeating with shorter intervals
|
||||
if time_since_press > DELETE_REPEAT_DELAY:
|
||||
time_since_last_repeat = current_time - self._backspace_last_repeat
|
||||
if time_since_last_repeat > DELETE_REPEAT_INTERVAL:
|
||||
self._input_box.delete_char_before_cursor()
|
||||
self._backspace_last_repeat = current_time
|
||||
|
||||
layout = KEYBOARD_LAYOUTS[self._layout_name]
|
||||
|
||||
h_space, v_space = 15, 15
|
||||
row_y_start = rect.y + 300 # Starting Y position for the first row
|
||||
key_height = (rect.height - 300 - 3 * v_space) / 4
|
||||
key_max_width = (rect.width - (len(layout[2]) - 1) * h_space) / len(layout[2])
|
||||
|
||||
# Iterate over the rows of keys in the current layout
|
||||
for row, keys in enumerate(layout):
|
||||
key_width = min((rect.width - (180 if row == 1 else 0) - h_space * (len(keys) - 1)) / len(keys), key_max_width)
|
||||
start_x = rect.x + (90 if row == 1 else 0)
|
||||
|
||||
for i, key in enumerate(keys):
|
||||
if i > 0:
|
||||
start_x += h_space
|
||||
|
||||
new_width = (key_width * 3 + h_space * 2) if key == SPACE_KEY else (key_width * 2 + h_space if key == ENTER_KEY else key_width)
|
||||
key_rect = rl.Rectangle(start_x, row_y_start + row * (key_height + v_space), new_width, key_height)
|
||||
start_x += new_width
|
||||
|
||||
is_enabled = key != ENTER_KEY or len(self._input_box.text) >= self._min_text_size
|
||||
|
||||
if key == BACKSPACE_KEY and self._all_keys[BACKSPACE_KEY].is_pressed and not self._backspace_pressed:
|
||||
self._backspace_pressed = True
|
||||
self._backspace_press_time = time.monotonic()
|
||||
self._backspace_last_repeat = time.monotonic()
|
||||
|
||||
if key in self._key_icons:
|
||||
if key == SHIFT_ACTIVE_KEY and self._caps_lock:
|
||||
key = CAPS_LOCK_KEY
|
||||
self._all_keys[key].set_enabled(is_enabled)
|
||||
self._all_keys[key].render(key_rect)
|
||||
else:
|
||||
self._all_keys[key].set_enabled(is_enabled)
|
||||
self._all_keys[key].render(key_rect)
|
||||
|
||||
def _render_input_area(self, input_rect: rl.Rectangle):
|
||||
if self._show_password_toggle:
|
||||
self._input_box.set_password_mode(self._password_mode)
|
||||
self._input_box.render(rl.Rectangle(input_rect.x, input_rect.y, input_rect.width - 100, input_rect.height))
|
||||
|
||||
# render eye icon
|
||||
eye_texture = self._eye_closed_texture if self._password_mode else self._eye_open_texture
|
||||
|
||||
eye_rect = rl.Rectangle(input_rect.x + input_rect.width - 90, input_rect.y, 80, input_rect.height)
|
||||
self._eye_button.render(eye_rect)
|
||||
|
||||
eye_x = eye_rect.x + (eye_rect.width - eye_texture.width) / 2
|
||||
eye_y = eye_rect.y + (eye_rect.height - eye_texture.height) / 2
|
||||
|
||||
rl.draw_texture_v(eye_texture, rl.Vector2(eye_x, eye_y), rl.WHITE)
|
||||
else:
|
||||
self._input_box.render(input_rect)
|
||||
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(input_rect.x, input_rect.y + input_rect.height - 2),
|
||||
rl.Vector2(input_rect.x + input_rect.width, input_rect.y + input_rect.height - 2),
|
||||
3.0, # 3 pixel thickness
|
||||
rl.Color(189, 189, 189, 255),
|
||||
)
|
||||
|
||||
def handle_key_press(self, key):
|
||||
if key in (CAPS_LOCK_KEY, ABC_KEY):
|
||||
self._caps_lock = False
|
||||
self._layout_name = "lowercase"
|
||||
elif key == SHIFT_INACTIVE_KEY:
|
||||
self._last_shift_press_time = time.monotonic()
|
||||
self._layout_name = "uppercase"
|
||||
elif key == SHIFT_ACTIVE_KEY:
|
||||
if time.monotonic() - self._last_shift_press_time < DOUBLE_CLICK_THRESHOLD:
|
||||
self._caps_lock = True
|
||||
else:
|
||||
self._layout_name = "lowercase"
|
||||
elif key == NUMERIC_KEY:
|
||||
self._layout_name = "numbers"
|
||||
elif key == SYMBOL_KEY:
|
||||
self._layout_name = "specials"
|
||||
elif key == BACKSPACE_KEY:
|
||||
self._input_box.delete_char_before_cursor()
|
||||
else:
|
||||
self._input_box.add_char_at_cursor(key)
|
||||
if not self._caps_lock and self._layout_name == "uppercase":
|
||||
self._layout_name = "lowercase"
|
||||
|
||||
def reset(self, min_text_size: int | None = None):
|
||||
if min_text_size is not None:
|
||||
self._min_text_size = min_text_size
|
||||
self._last_shift_press_time = 0
|
||||
self._backspace_pressed = False
|
||||
self._backspace_press_time = 0.0
|
||||
self._backspace_last_repeat = 0.0
|
||||
self.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def callback(result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
print(f"You typed: {keyboard.text}")
|
||||
elif result == DialogResult.CANCEL:
|
||||
print("Canceled")
|
||||
gui_app.request_close()
|
||||
|
||||
gui_app.init_window("Keyboard")
|
||||
keyboard = Keyboard(min_text_size=8, show_password_toggle=True, callback=callback)
|
||||
keyboard.set_title("Keyboard Input", "Type your text below")
|
||||
|
||||
gui_app.push_widget(keyboard)
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
gui_app.close()
|
||||
@@ -0,0 +1,722 @@
|
||||
import math
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
from itertools import zip_longest
|
||||
from typing import Union
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.utils import GuiStyleContext
|
||||
from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex
|
||||
from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
|
||||
ICON_PADDING = 15
|
||||
|
||||
|
||||
# TODO: make this common
|
||||
def _resolve_value(value, default=""):
|
||||
if callable(value):
|
||||
return value()
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
class ScrollState(IntEnum):
|
||||
STARTING = 0
|
||||
SCROLLING = 1
|
||||
|
||||
|
||||
# TODO: This should be a Widget class
|
||||
def gui_label(
|
||||
rect: rl.Rectangle,
|
||||
text: str,
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
elide_right: bool = True
|
||||
):
|
||||
font = gui_app.font(font_weight)
|
||||
text_size = measure_text_cached(font, text, font_size)
|
||||
display_text = text
|
||||
|
||||
# Elide text to fit within the rectangle
|
||||
if elide_right and text_size.x > rect.width:
|
||||
_ellipsis = "..."
|
||||
left, right = 0, len(text)
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
candidate = text[:mid] + _ellipsis
|
||||
candidate_size = measure_text_cached(font, candidate, font_size)
|
||||
if candidate_size.x <= rect.width:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
|
||||
text_size = measure_text_cached(font, display_text, font_size)
|
||||
|
||||
# Calculate horizontal position based on alignment
|
||||
text_x = rect.x + {
|
||||
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
|
||||
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
|
||||
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
|
||||
}.get(alignment, 0)
|
||||
|
||||
# Calculate vertical position based on alignment
|
||||
text_y = rect.y + {
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
|
||||
}.get(alignment_vertical, 0)
|
||||
|
||||
# Draw the text in the specified rectangle
|
||||
# TODO: add wrapping and proper centering for multiline text
|
||||
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
||||
|
||||
|
||||
def gui_text_box(
|
||||
rect: rl.Rectangle,
|
||||
text: str,
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
line_scale: float = 1.0,
|
||||
):
|
||||
styles = [
|
||||
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
|
||||
]
|
||||
if font_weight != FontWeight.NORMAL:
|
||||
rl.gui_set_font(gui_app.font(font_weight))
|
||||
|
||||
with GuiStyleContext(styles):
|
||||
rl.gui_label(rect, text)
|
||||
|
||||
if font_weight != FontWeight.NORMAL:
|
||||
rl.gui_set_font(gui_app.font(FontWeight.NORMAL))
|
||||
|
||||
|
||||
# Non-interactive text area. Can render emojis and an optional specified icon.
|
||||
class Label(Widget):
|
||||
def __init__(self,
|
||||
text: str | Callable[[], str],
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
text_padding: int = 0,
|
||||
text_color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
icon: Union[rl.Texture, None] = None,
|
||||
elide_right: bool = False,
|
||||
line_scale=1.0,
|
||||
):
|
||||
|
||||
super().__init__()
|
||||
self._font_weight = font_weight
|
||||
self._font = gui_app.font(self._font_weight)
|
||||
self._font_size = font_size
|
||||
self._text_alignment = text_alignment
|
||||
self._text_alignment_vertical = text_alignment_vertical
|
||||
self._text_padding = text_padding
|
||||
self._text_color = text_color
|
||||
self._icon = icon
|
||||
self._elide_right = elide_right
|
||||
self._line_scale = line_scale
|
||||
|
||||
self._text = text
|
||||
self.set_text(text)
|
||||
|
||||
def set_text(self, text):
|
||||
self._text = text
|
||||
self._update_text(self._text)
|
||||
|
||||
def set_text_color(self, color):
|
||||
self._text_color = color
|
||||
|
||||
def set_font_size(self, size):
|
||||
self._font_size = size
|
||||
self._update_text(self._text)
|
||||
|
||||
def _update_text(self, text):
|
||||
self._emojis = []
|
||||
self._text_size = []
|
||||
text = _resolve_value(text)
|
||||
|
||||
if self._elide_right:
|
||||
display_text = text
|
||||
|
||||
# Elide text to fit within the rectangle
|
||||
text_size = measure_text_cached(self._font, text, self._font_size)
|
||||
content_width = self._rect.width - self._text_padding * 2
|
||||
if self._icon:
|
||||
content_width -= self._icon.width + ICON_PADDING
|
||||
if text_size.x > content_width:
|
||||
_ellipsis = "..."
|
||||
left, right = 0, len(text)
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
candidate = text[:mid] + _ellipsis
|
||||
candidate_size = measure_text_cached(self._font, candidate, self._font_size)
|
||||
if candidate_size.x <= content_width:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
|
||||
|
||||
self._text_wrapped = [display_text]
|
||||
else:
|
||||
self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2)))
|
||||
|
||||
for t in self._text_wrapped:
|
||||
self._emojis.append(find_emoji(t))
|
||||
self._text_size.append(measure_text_cached(self._font, t, self._font_size))
|
||||
|
||||
def _render(self, _):
|
||||
# Text can be a callable
|
||||
# TODO: cache until text changed
|
||||
self._update_text(self._text)
|
||||
|
||||
text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0)
|
||||
if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE:
|
||||
total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE
|
||||
text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2))
|
||||
else:
|
||||
text_pos = rl.Vector2(self._rect.x, self._rect.y)
|
||||
|
||||
if self._icon:
|
||||
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
|
||||
if len(self._text_wrapped) > 0:
|
||||
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
||||
icon_x = self._rect.x + self._text_padding
|
||||
text_pos.x = self._icon.width + ICON_PADDING
|
||||
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
||||
total_width = self._icon.width + ICON_PADDING + text_size.x
|
||||
icon_x = self._rect.x + (self._rect.width - total_width) / 2
|
||||
text_pos.x = self._icon.width + ICON_PADDING
|
||||
else:
|
||||
icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - ICON_PADDING - self._icon.width
|
||||
else:
|
||||
icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2
|
||||
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE)
|
||||
|
||||
for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]):
|
||||
line_pos = rl.Vector2(text_pos.x, text_pos.y)
|
||||
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
||||
line_pos.x += self._text_padding
|
||||
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
||||
line_pos.x += (self._rect.width - text_size.x) // 2
|
||||
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
||||
line_pos.x += self._rect.width - text_size.x - self._text_padding
|
||||
|
||||
prev_index = 0
|
||||
for start, end, emoji in emojis:
|
||||
text_before = text[prev_index:start]
|
||||
width_before = measure_text_cached(self._font, text_before, self._font_size)
|
||||
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color)
|
||||
line_pos.x += width_before.x
|
||||
|
||||
tex = emoji_tex(emoji)
|
||||
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
|
||||
line_pos.x += self._font_size * FONT_SCALE
|
||||
prev_index = end
|
||||
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
|
||||
text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale
|
||||
|
||||
|
||||
class UnifiedLabel(Widget):
|
||||
"""
|
||||
Unified label widget that combines functionality from gui_label, gui_text_box, and Label.
|
||||
|
||||
Supports:
|
||||
- Emoji rendering
|
||||
- Text wrapping
|
||||
- Automatic eliding (single-line or multiline)
|
||||
- Proper multiline vertical alignment
|
||||
- Height calculation for layout purposes
|
||||
"""
|
||||
# Shimmer constants
|
||||
SHIMMER_BAND_WIDTH = 0.3 # shimmer width as fraction of text width
|
||||
SHIMMER_BLUR_RADIUS = 0.12 # gaussian blur as fraction of text width
|
||||
SHIMMER_CYCLE_PERIOD = 2.5 # seconds per full shimmer cycle
|
||||
SHIMMER_SWEEP_FRACTION = 0.9 # fraction of cycle spent sweeping (rest is pause)
|
||||
SHIMMER_LOW_OPACITY = 0.65 # text opacity at rest, shimmer brings to 1.0
|
||||
|
||||
def __init__(self,
|
||||
text: str | Callable[[], str],
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
text_color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
text_padding: int = 0,
|
||||
max_width: int | None = None,
|
||||
elide: bool = True,
|
||||
wrap_text: bool = True,
|
||||
scroll: bool = False,
|
||||
line_height: float = 1.0,
|
||||
letter_spacing: float = 0.0,
|
||||
shimmer: bool = False):
|
||||
super().__init__()
|
||||
self._text = text
|
||||
self._font_size = font_size
|
||||
self._font_weight = font_weight
|
||||
self._font = gui_app.font(self._font_weight)
|
||||
self._text_color = text_color
|
||||
self._alignment = alignment
|
||||
self._alignment_vertical = alignment_vertical
|
||||
self._text_padding = text_padding
|
||||
self._max_width = max_width
|
||||
self._elide = elide
|
||||
self._wrap_text = wrap_text
|
||||
self._scroll = scroll
|
||||
self._line_height = line_height * 0.9
|
||||
self._letter_spacing = letter_spacing # 0.1 = 10%
|
||||
self._spacing_pixels = font_size * letter_spacing
|
||||
|
||||
# Shimmer state
|
||||
self._shimmer = shimmer
|
||||
self._shimmer_start_time = 0.0
|
||||
|
||||
# Scroll state
|
||||
self._scroll = scroll
|
||||
self._needs_scroll = False
|
||||
self._scroll_offset = 0
|
||||
self._scroll_pause_t: float | None = None
|
||||
self._scroll_state: ScrollState = ScrollState.STARTING
|
||||
|
||||
# Scroll mode does not support eliding or multiline wrapping
|
||||
if self._scroll:
|
||||
self._elide = False
|
||||
self._wrap_text = False
|
||||
|
||||
# Cached data
|
||||
self._cached_text: str | None = None
|
||||
self._cached_wrapped_lines: list[str] = []
|
||||
self._cached_line_sizes: list[rl.Vector2] = []
|
||||
self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
|
||||
self._cached_total_height: float | None = None
|
||||
self._cached_width: int = -1
|
||||
|
||||
# If max_width is set, initialize rect size for Scroller support
|
||||
if max_width is not None:
|
||||
self._rect.width = max_width
|
||||
self._rect.height = self.get_content_height(max_width)
|
||||
|
||||
def set_text(self, text: str | Callable[[], str]):
|
||||
"""Update the text content."""
|
||||
self._text = text
|
||||
# No need to update cache here, will be done on next render if needed
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get the current text content."""
|
||||
return str(_resolve_value(self._text))
|
||||
|
||||
@property
|
||||
def font_size(self) -> int:
|
||||
return self._font_size
|
||||
|
||||
@property
|
||||
def text_width(self) -> float:
|
||||
return max((s.x for s in self._cached_line_sizes), default=0.0)
|
||||
|
||||
def set_text_color(self, color: rl.Color):
|
||||
"""Update the text color."""
|
||||
self._text_color = color
|
||||
|
||||
def set_color(self, color: rl.Color):
|
||||
"""Update the text color (alias for set_text_color)."""
|
||||
self.set_text_color(color)
|
||||
|
||||
def set_font_size(self, size: int):
|
||||
"""Update the font size."""
|
||||
if self._font_size != size:
|
||||
self._font_size = size
|
||||
self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
|
||||
self._cached_text = None # Invalidate cache
|
||||
|
||||
def set_letter_spacing(self, letter_spacing: float):
|
||||
"""Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
|
||||
if self._letter_spacing != letter_spacing:
|
||||
self._letter_spacing = letter_spacing
|
||||
self._spacing_pixels = self._font_size * letter_spacing
|
||||
self._cached_text = None # Invalidate cache
|
||||
|
||||
def set_line_height(self, line_height: float):
|
||||
"""Update line height (multiplier, e.g., 1.0 = default)."""
|
||||
new_line_height = line_height * 0.9
|
||||
if self._line_height != new_line_height:
|
||||
self._line_height = new_line_height
|
||||
self._cached_text = None # Invalidate cache (affects total height)
|
||||
|
||||
def set_font_weight(self, font_weight: FontWeight):
|
||||
"""Update the font weight."""
|
||||
if self._font_weight != font_weight:
|
||||
self._font_weight = font_weight
|
||||
self._font = gui_app.font(self._font_weight)
|
||||
self._cached_text = None # Invalidate cache
|
||||
|
||||
def set_alignment(self, alignment: int):
|
||||
"""Update the horizontal text alignment."""
|
||||
self._alignment = alignment
|
||||
|
||||
def set_alignment_vertical(self, alignment_vertical: int):
|
||||
"""Update the vertical text alignment."""
|
||||
self._alignment_vertical = alignment_vertical
|
||||
|
||||
def reset_scroll(self):
|
||||
"""Reset scroll state to initial position."""
|
||||
self._scroll_offset = 0
|
||||
self._scroll_pause_t = None
|
||||
self._scroll_state = ScrollState.STARTING
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
if self._shimmer:
|
||||
self.reset_shimmer()
|
||||
|
||||
def reset_shimmer(self, offset: float = 0.0):
|
||||
"""Reset shimmer animation timing."""
|
||||
self._shimmer_start_time = rl.get_time() + offset
|
||||
|
||||
def set_max_width(self, max_width: int | None):
|
||||
"""Set the maximum width constraint for wrapping/eliding."""
|
||||
if self._max_width != max_width:
|
||||
self._max_width = max_width
|
||||
self._cached_text = None # Invalidate cache
|
||||
# Update rect size for Scroller support
|
||||
if max_width is not None:
|
||||
self._rect.width = max_width
|
||||
self._rect.height = self.get_content_height(max_width)
|
||||
|
||||
def _update_text_cache(self, available_width: int):
|
||||
"""Update cached text processing data."""
|
||||
text = self.text
|
||||
|
||||
# Check if cache is still valid
|
||||
if (self._cached_text == text and
|
||||
self._cached_width == available_width and
|
||||
self._cached_wrapped_lines):
|
||||
return
|
||||
|
||||
self._cached_text = text
|
||||
self._cached_width = available_width
|
||||
|
||||
# Determine wrapping width
|
||||
content_width = available_width - (self._text_padding * 2)
|
||||
if content_width <= 0:
|
||||
content_width = 1
|
||||
|
||||
# Wrap text if enabled
|
||||
if self._wrap_text:
|
||||
self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
|
||||
else:
|
||||
# Split by newlines but don't wrap
|
||||
self._cached_wrapped_lines = text.split('\n') if text else [""]
|
||||
|
||||
# Elide lines if needed (for width constraint)
|
||||
self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
|
||||
|
||||
if self._scroll:
|
||||
self._cached_wrapped_lines = self._cached_wrapped_lines[:1] # Only first line for scrolling
|
||||
|
||||
# Process each line: measure and find emojis
|
||||
self._cached_line_sizes = []
|
||||
self._cached_line_emojis = []
|
||||
|
||||
for line in self._cached_wrapped_lines:
|
||||
emojis = find_emoji(line)
|
||||
self._cached_line_emojis.append(emojis)
|
||||
# Empty lines should still have height (use font size as line height)
|
||||
if not line:
|
||||
size = rl.Vector2(0, self._font_size * FONT_SCALE)
|
||||
else:
|
||||
size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
|
||||
|
||||
# This is the only line
|
||||
if self._scroll:
|
||||
self._needs_scroll = size.x > content_width
|
||||
|
||||
self._cached_line_sizes.append(size)
|
||||
|
||||
# Calculate total height
|
||||
# Each line contributes its measured height * line_height (matching Label's behavior)
|
||||
# This includes spacing to the next line
|
||||
if self._cached_line_sizes:
|
||||
# Match the rendering logic: first line doesn't get line_height scaling
|
||||
total_height = 0.0
|
||||
for idx, size in enumerate(self._cached_line_sizes):
|
||||
if idx == 0:
|
||||
total_height += size.y
|
||||
else:
|
||||
total_height += size.y * self._line_height
|
||||
self._cached_total_height = total_height
|
||||
else:
|
||||
self._cached_total_height = 0.0
|
||||
|
||||
def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
|
||||
"""Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
|
||||
if not self._elide and not force:
|
||||
return line
|
||||
|
||||
text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
|
||||
if text_size.x <= max_width and not force:
|
||||
return line
|
||||
|
||||
ellipsis = "..."
|
||||
# If force=True and line fits, just append ellipsis without truncating
|
||||
if force and text_size.x <= max_width:
|
||||
ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
|
||||
if text_size.x + ellipsis_size.x <= max_width:
|
||||
return line + ellipsis
|
||||
# If line + ellipsis doesn't fit, need to truncate
|
||||
# Fall through to binary search below
|
||||
|
||||
left, right = 0, len(line)
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
candidate = line[:mid] + ellipsis
|
||||
candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
|
||||
if candidate_size.x <= max_width:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
return line[:left - 1] + ellipsis if left > 0 else ellipsis
|
||||
|
||||
def get_content_height(self, max_width: int) -> float:
|
||||
"""
|
||||
Returns the height needed for text at given max_width.
|
||||
Similar to HtmlRenderer.get_total_height().
|
||||
"""
|
||||
# Use max_width if provided, otherwise use self._max_width or a default
|
||||
width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
|
||||
self._update_text_cache(width)
|
||||
|
||||
if self._cached_total_height is not None:
|
||||
return self._cached_total_height
|
||||
return 0.0
|
||||
|
||||
def _render(self, _):
|
||||
"""Render the label."""
|
||||
if self._rect.width <= 0 or self._rect.height <= 0:
|
||||
return
|
||||
|
||||
# Determine available width
|
||||
available_width = self._rect.width
|
||||
if self._max_width is not None:
|
||||
available_width = min(available_width, self._max_width)
|
||||
|
||||
# Update text cache
|
||||
self._update_text_cache(int(available_width))
|
||||
|
||||
if not self._cached_wrapped_lines:
|
||||
return
|
||||
|
||||
# Calculate which lines fit in the available height
|
||||
visible_lines: list[str] = []
|
||||
visible_sizes: list[rl.Vector2] = []
|
||||
visible_emojis: list[list[tuple[int, int, str]]] = []
|
||||
|
||||
current_height = 0.0
|
||||
broke_early = False
|
||||
for line, size, emojis in zip(
|
||||
self._cached_wrapped_lines,
|
||||
self._cached_line_sizes,
|
||||
self._cached_line_emojis,
|
||||
strict=True):
|
||||
|
||||
# Calculate height needed for this line
|
||||
# Each line contributes its height * line_height (matching Label's behavior)
|
||||
line_height_needed = size.y * self._line_height
|
||||
|
||||
# Check if this line fits
|
||||
if current_height + line_height_needed > self._rect.height:
|
||||
# This line doesn't fit
|
||||
if len(visible_lines) == 0:
|
||||
# First line doesn't fit by height - still show it (will be clipped by scissor if needed)
|
||||
# Continue to add this line below
|
||||
pass
|
||||
else:
|
||||
# We have visible lines and this one doesn't fit - mark that we broke early
|
||||
broke_early = True
|
||||
break
|
||||
|
||||
visible_lines.append(line)
|
||||
visible_sizes.append(size)
|
||||
visible_emojis.append(emojis)
|
||||
|
||||
current_height += line_height_needed
|
||||
|
||||
# If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
|
||||
if broke_early and len(visible_lines) > 0 and self._elide:
|
||||
content_width = int(available_width - (self._text_padding * 2))
|
||||
if content_width <= 0:
|
||||
content_width = 1
|
||||
|
||||
last_line_idx = len(visible_lines) - 1
|
||||
last_line = visible_lines[last_line_idx]
|
||||
# Force elide the last line to show "..." even if it fits in width (to indicate more content)
|
||||
elided = self._elide_line(last_line, content_width, force=True)
|
||||
visible_lines[last_line_idx] = elided
|
||||
visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
|
||||
|
||||
if not visible_lines:
|
||||
return
|
||||
|
||||
# Calculate total visible text block height
|
||||
# First line is not changed by line_height scaling
|
||||
total_visible_height = 0.0
|
||||
for idx, size in enumerate(visible_sizes):
|
||||
if idx == 0:
|
||||
total_visible_height += size.y
|
||||
else:
|
||||
total_visible_height += size.y * self._line_height
|
||||
|
||||
# Calculate vertical alignment offset
|
||||
if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
|
||||
start_y = self._rect.y
|
||||
elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
|
||||
start_y = self._rect.y + self._rect.height - total_visible_height
|
||||
else: # TEXT_ALIGN_MIDDLE
|
||||
start_y = self._rect.y + (self._rect.height - total_visible_height) / 2
|
||||
|
||||
# Only scissor when we know there is a single scrolling line
|
||||
# Pad a little since descenders like g or j may overflow below rect from font_scale
|
||||
if self._needs_scroll:
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y - self._font_size / 2), int(self._rect.width), int(self._rect.height + self._font_size))
|
||||
|
||||
# Render each line
|
||||
current_y = start_y
|
||||
for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
|
||||
if self._needs_scroll:
|
||||
if self._scroll_state == ScrollState.STARTING:
|
||||
if self._scroll_pause_t is None:
|
||||
self._scroll_pause_t = rl.get_time() + 2.0
|
||||
if rl.get_time() >= self._scroll_pause_t:
|
||||
self._scroll_state = ScrollState.SCROLLING
|
||||
self._scroll_pause_t = None
|
||||
|
||||
elif self._scroll_state == ScrollState.SCROLLING:
|
||||
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
|
||||
# don't fully hide
|
||||
if self._scroll_offset <= -size.x - self._rect.width / 3:
|
||||
self._scroll_offset = 0
|
||||
self._scroll_state = ScrollState.STARTING
|
||||
self._scroll_pause_t = None
|
||||
else:
|
||||
self.reset_scroll()
|
||||
|
||||
self._render_line(line, size, emojis, current_y)
|
||||
|
||||
# Draw 2nd instance for scrolling
|
||||
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
|
||||
text2_scroll_offset = size.x + self._rect.width / 3
|
||||
self._render_line(line, size, emojis, current_y, text2_scroll_offset)
|
||||
|
||||
# Move to next line (if not last line)
|
||||
if idx < len(visible_lines) - 1:
|
||||
# Use current line's height * line_height for spacing to next line
|
||||
current_y += size.y * self._line_height
|
||||
|
||||
if self._needs_scroll:
|
||||
# draw black fade on left and right
|
||||
fade_width = 20
|
||||
rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - fade_width), int(self._rect.y), fade_width, int(self._rect.height), rl.BLANK, rl.BLACK)
|
||||
|
||||
# stop drawing left fade once text scrolls past
|
||||
text_width = visible_sizes[0].x if visible_sizes else 0
|
||||
first_copy_in_view = self._scroll_offset + text_width > 0
|
||||
draw_left_fade = self._scroll_state != ScrollState.STARTING and first_copy_in_view
|
||||
if draw_left_fade:
|
||||
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), fade_width, int(self._rect.height), rl.BLACK, rl.BLANK)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _shimmer_alpha(self, char_x: float, shimmer_left: float, shimmer_width: float) -> float:
|
||||
"""Compute shimmer opacity multiplier for a character at the given x position."""
|
||||
sigma = shimmer_width * self.SHIMMER_BLUR_RADIUS
|
||||
if sigma <= 0:
|
||||
return self.SHIMMER_LOW_OPACITY
|
||||
|
||||
elapsed = rl.get_time() - self._shimmer_start_time
|
||||
t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD
|
||||
t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0))
|
||||
t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) # smoothstep
|
||||
|
||||
margin = shimmer_width * self.SHIMMER_BAND_WIDTH
|
||||
center = shimmer_left + shimmer_width + margin - t * (shimmer_width + 2.0 * margin)
|
||||
|
||||
d = char_x - center
|
||||
shimmer = math.exp(-0.5 * d * d / (sigma * sigma))
|
||||
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
|
||||
|
||||
def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
|
||||
# Calculate horizontal position
|
||||
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
||||
line_x = self._rect.x + self._text_padding
|
||||
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
||||
line_x = self._rect.x + (self._rect.width - size.x) / 2
|
||||
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
||||
line_x = self._rect.x + self._rect.width - size.x - self._text_padding
|
||||
else:
|
||||
line_x = self._rect.x + self._text_padding
|
||||
line_x += self._scroll_offset + x_offset
|
||||
|
||||
if self._shimmer:
|
||||
self._render_line_shimmer(line, line_x, current_y)
|
||||
else:
|
||||
# Render line with emojis
|
||||
self._render_line_normal(line, emojis, line_x, current_y)
|
||||
|
||||
def _render_line_normal(self, line, emojis, line_x, current_y):
|
||||
line_pos = rl.Vector2(line_x, current_y)
|
||||
prev_index = 0
|
||||
|
||||
for start, end, emoji in emojis:
|
||||
# Draw text before emoji
|
||||
text_before = line[prev_index:start]
|
||||
if text_before:
|
||||
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
|
||||
width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
|
||||
line_pos.x += width_before.x
|
||||
|
||||
# Draw emoji
|
||||
tex = emoji_tex(emoji)
|
||||
emoji_scale = self._font_size / tex.height * FONT_SCALE
|
||||
rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
|
||||
# Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
|
||||
line_pos.x += self._font_size * FONT_SCALE
|
||||
prev_index = end
|
||||
|
||||
# Draw remaining text after last emoji
|
||||
text_after = line[prev_index:]
|
||||
if text_after:
|
||||
rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
|
||||
|
||||
def _render_line_shimmer(self, line, line_x, current_y):
|
||||
# Shimmer range based on widest line so sweep is even across all lines
|
||||
max_width = self.text_width
|
||||
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
||||
shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width
|
||||
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
||||
shimmer_left = self._rect.x + (self._rect.width - max_width) / 2
|
||||
else:
|
||||
shimmer_left = self._rect.x + self._text_padding
|
||||
|
||||
base_a = self._text_color.a / 255.0
|
||||
cursor_x = line_x
|
||||
for ch in line:
|
||||
char_width = measure_text_cached(self._font, ch, self._font_size, self._spacing_pixels).x
|
||||
char_center_x = cursor_x + char_width / 2.0
|
||||
alpha = int(255 * self._shimmer_alpha(char_center_x, shimmer_left, max_width) * base_a)
|
||||
color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, alpha)
|
||||
rl.draw_text_ex(self._font, ch, rl.Vector2(cursor_x, current_y), self._font_size, 0, color)
|
||||
cursor_x += char_width + self._spacing_pixels
|
||||
@@ -0,0 +1,59 @@
|
||||
from enum import IntFlag
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
|
||||
class Alignment(IntFlag):
|
||||
LEFT = 0
|
||||
# TODO: implement
|
||||
# H_CENTER = 2
|
||||
# RIGHT = 4
|
||||
|
||||
TOP = 8
|
||||
V_CENTER = 16
|
||||
BOTTOM = 32
|
||||
|
||||
|
||||
class HBoxLayout(Widget):
|
||||
"""
|
||||
A Widget that lays out child Widgets horizontally.
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: list[Widget] | None = None, spacing: int = 0,
|
||||
alignment: Alignment = Alignment.LEFT | Alignment.V_CENTER):
|
||||
super().__init__()
|
||||
self._spacing = spacing
|
||||
self._alignment = alignment
|
||||
|
||||
if widgets is not None:
|
||||
for widget in widgets:
|
||||
self.add_widget(widget)
|
||||
|
||||
@property
|
||||
def widgets(self) -> list[Widget]:
|
||||
return self._children
|
||||
|
||||
def add_widget(self, widget: Widget) -> None:
|
||||
self._child(widget)
|
||||
|
||||
def _render(self, _):
|
||||
visible_widgets = [w for w in self._children if w.is_visible]
|
||||
|
||||
cur_offset_x = 0
|
||||
|
||||
for idx, widget in enumerate(visible_widgets):
|
||||
spacing = self._spacing if (idx > 0) else 0
|
||||
|
||||
x = self._rect.x + cur_offset_x + spacing
|
||||
cur_offset_x += widget.rect.width + spacing
|
||||
|
||||
if self._alignment & Alignment.TOP:
|
||||
y = self._rect.y
|
||||
elif self._alignment & Alignment.BOTTOM:
|
||||
y = self._rect.y + self._rect.height - widget.rect.height
|
||||
else: # center
|
||||
y = self._rect.y + (self._rect.height - widget.rect.height) / 2
|
||||
|
||||
# Update widget position and render
|
||||
widget.set_position(x, y)
|
||||
widget.set_parent_rect(self._rect)
|
||||
widget.render()
|
||||
@@ -0,0 +1,468 @@
|
||||
import os
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from abc import ABC
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
|
||||
|
||||
ITEM_BASE_WIDTH = 600
|
||||
ITEM_BASE_HEIGHT = 170
|
||||
ITEM_PADDING = 20
|
||||
ITEM_TEXT_FONT_SIZE = 50
|
||||
ITEM_TEXT_COLOR = rl.WHITE
|
||||
ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255)
|
||||
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
|
||||
ITEM_DESC_FONT_SIZE = 40
|
||||
ITEM_DESC_V_OFFSET = 140
|
||||
RIGHT_ITEM_PADDING = 20
|
||||
ICON_SIZE = 80
|
||||
BUTTON_WIDTH = 250
|
||||
BUTTON_HEIGHT = 100
|
||||
BUTTON_BORDER_RADIUS = 50
|
||||
BUTTON_FONT_SIZE = 35
|
||||
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
|
||||
|
||||
TEXT_PADDING = 20
|
||||
|
||||
|
||||
def _resolve_value(value, default=""):
|
||||
if callable(value):
|
||||
return value()
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
# Abstract base class for right-side items
|
||||
class ItemAction(Widget, ABC):
|
||||
def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, width, 0))
|
||||
self._enabled_source = enabled
|
||||
|
||||
def get_width_hint(self) -> float:
|
||||
# Return's action ideal width, 0 means use full width
|
||||
return self._rect.width
|
||||
|
||||
def set_enabled(self, enabled: bool | Callable[[], bool]):
|
||||
self._enabled_source = enabled
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return _resolve_value(self._enabled_source, False)
|
||||
|
||||
|
||||
class ToggleAction(ItemAction):
|
||||
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
|
||||
callback: Callable[[bool], None] | None = None):
|
||||
super().__init__(width, enabled)
|
||||
self.toggle = Toggle(initial_state=initial_state, callback=callback)
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(touch_callback)
|
||||
self.toggle.set_touch_valid_callback(touch_callback)
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool:
|
||||
self.toggle.set_enabled(self.enabled)
|
||||
clicked = self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT))
|
||||
return bool(clicked)
|
||||
|
||||
def set_state(self, state: bool):
|
||||
self.toggle.set_state(state)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self.toggle.get_state()
|
||||
|
||||
|
||||
class ButtonAction(ItemAction):
|
||||
def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True):
|
||||
super().__init__(width, enabled)
|
||||
self._text_source = text
|
||||
self._value_source: str | Callable[[], str] | None = None
|
||||
self._pressed = False
|
||||
self._font = gui_app.font(FontWeight.NORMAL)
|
||||
|
||||
def pressed():
|
||||
self._pressed = True
|
||||
|
||||
self._button = Button(
|
||||
self.text,
|
||||
font_size=BUTTON_FONT_SIZE,
|
||||
font_weight=BUTTON_FONT_WEIGHT,
|
||||
button_style=ButtonStyle.LIST_ACTION,
|
||||
border_radius=BUTTON_BORDER_RADIUS,
|
||||
click_callback=pressed,
|
||||
text_padding=0,
|
||||
)
|
||||
self.set_enabled(enabled)
|
||||
|
||||
def get_width_hint(self) -> float:
|
||||
value_text = self.value
|
||||
if value_text:
|
||||
text_width = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x
|
||||
return text_width + BUTTON_WIDTH + TEXT_PADDING
|
||||
else:
|
||||
return BUTTON_WIDTH
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(touch_callback)
|
||||
self._button.set_touch_valid_callback(touch_callback)
|
||||
|
||||
def set_text(self, text: str | Callable[[], str]):
|
||||
self._text_source = text
|
||||
|
||||
def set_value(self, value: str | Callable[[], str]):
|
||||
self._value_source = value
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return _resolve_value(self._text_source, tr("Error"))
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return _resolve_value(self._value_source, "")
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool:
|
||||
self._button.set_text(self.text)
|
||||
self._button.set_enabled(_resolve_value(self.enabled))
|
||||
button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
self._button.render(button_rect)
|
||||
|
||||
value_text = self.value
|
||||
if value_text:
|
||||
value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)
|
||||
gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR,
|
||||
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
# TODO: just use the generic Widget click callbacks everywhere, no returning from render
|
||||
pressed = self._pressed
|
||||
self._pressed = False
|
||||
return pressed
|
||||
|
||||
|
||||
class TextAction(ItemAction):
|
||||
def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
|
||||
self._text_source = text
|
||||
self.color = color
|
||||
|
||||
self._font = gui_app.font(FontWeight.NORMAL)
|
||||
initial_text = _resolve_value(text, "")
|
||||
text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x
|
||||
super().__init__(int(text_width + TEXT_PADDING), enabled)
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return _resolve_value(self._text_source, tr("Error"))
|
||||
|
||||
def get_width_hint(self) -> float:
|
||||
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
|
||||
return text_width + TEXT_PADDING
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> bool:
|
||||
gui_label(self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color,
|
||||
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
return False
|
||||
|
||||
def set_text(self, text: str | Callable[[], str]):
|
||||
self._text_source = text
|
||||
|
||||
|
||||
class DualButtonAction(ItemAction):
|
||||
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None,
|
||||
right_callback: Callable | None = None, enabled: bool | Callable[[], bool] = True):
|
||||
super().__init__(width=0, enabled=enabled) # Width 0 means use full width
|
||||
self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0)
|
||||
self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0)
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(touch_callback)
|
||||
self.left_button.set_touch_valid_callback(touch_callback)
|
||||
self.right_button.set_touch_valid_callback(touch_callback)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
button_spacing = 30
|
||||
button_height = 120
|
||||
button_width = (rect.width - button_spacing) / 2
|
||||
button_y = rect.y + (rect.height - button_height) / 2
|
||||
|
||||
left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
|
||||
right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
|
||||
|
||||
# expand one to full width if other is not visible
|
||||
if not self.left_button.is_visible:
|
||||
right_rect.x = rect.x
|
||||
right_rect.width = rect.width
|
||||
elif not self.right_button.is_visible:
|
||||
left_rect.width = rect.width
|
||||
|
||||
# Render buttons
|
||||
self.left_button.render(left_rect)
|
||||
self.right_button.render(right_rect)
|
||||
|
||||
|
||||
class MultipleButtonAction(ItemAction):
|
||||
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None):
|
||||
super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=True)
|
||||
self.buttons = buttons
|
||||
self.button_width = button_width
|
||||
self.selected_button = selected_index
|
||||
self.callback = callback
|
||||
self._font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
def set_selected_button(self, index: int):
|
||||
if 0 <= index < len(self.buttons):
|
||||
self.selected_button = index
|
||||
|
||||
def get_selected_button(self) -> int:
|
||||
return self.selected_button
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
spacing = RIGHT_ITEM_PADDING
|
||||
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
|
||||
|
||||
for i, _text in enumerate(self.buttons):
|
||||
button_x = rect.x + i * (self.button_width + spacing)
|
||||
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
|
||||
|
||||
# Check button state
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
is_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled and self.is_pressed
|
||||
is_selected = i == self.selected_button
|
||||
|
||||
# Button colors
|
||||
if is_selected:
|
||||
bg_color = rl.Color(51, 171, 76, 255) # Green
|
||||
elif is_pressed:
|
||||
bg_color = rl.Color(74, 74, 74, 255) # Dark gray
|
||||
else:
|
||||
bg_color = rl.Color(57, 57, 57, 255) # Gray
|
||||
|
||||
if not self.enabled:
|
||||
bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim
|
||||
|
||||
# Draw button
|
||||
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
|
||||
|
||||
# Draw text
|
||||
text = _resolve_value(_text, "")
|
||||
text_size = measure_text_cached(self._font, text, 40)
|
||||
text_x = button_x + (self.button_width - text_size.x) / 2
|
||||
text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
|
||||
text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255)
|
||||
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
spacing = RIGHT_ITEM_PADDING
|
||||
button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2
|
||||
for i, _ in enumerate(self.buttons):
|
||||
button_x = self._rect.x + i * (self.button_width + spacing)
|
||||
button_rect = rl.Rectangle(button_x, button_y, self.button_width, BUTTON_HEIGHT)
|
||||
if rl.check_collision_point_rec(mouse_pos, button_rect):
|
||||
self.selected_button = i
|
||||
if self.callback:
|
||||
self.callback(i)
|
||||
|
||||
|
||||
class ListItem(Widget):
|
||||
def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
|
||||
description_visible: bool = False, callback: Callable | None = None,
|
||||
action_item: ItemAction | None = None):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self.set_icon(icon)
|
||||
self._description = description
|
||||
self.description_visible = description_visible
|
||||
self.callback = callback
|
||||
self.description_opened_callback: Callable | None = None
|
||||
self.action_item = action_item
|
||||
|
||||
self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
|
||||
self._font = gui_app.font(FontWeight.NORMAL)
|
||||
|
||||
self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE},
|
||||
text_color=ITEM_DESC_TEXT_COLOR)
|
||||
self._parse_description(self.description)
|
||||
|
||||
# Cached properties for performance
|
||||
self._prev_description: str | None = self.description
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._set_description_visible(False)
|
||||
|
||||
def set_description_opened_callback(self, callback: Callable) -> None:
|
||||
self.description_opened_callback = callback
|
||||
|
||||
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
|
||||
super().set_touch_valid_callback(touch_callback)
|
||||
if self.action_item:
|
||||
self.action_item.set_touch_valid_callback(touch_callback)
|
||||
|
||||
def set_parent_rect(self, parent_rect: rl.Rectangle):
|
||||
super().set_parent_rect(parent_rect)
|
||||
self._rect.width = parent_rect.width
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if not self.is_visible:
|
||||
return
|
||||
|
||||
# Check not in action rect
|
||||
if self.action_item:
|
||||
action_rect = self.get_right_item_rect(self._rect)
|
||||
if rl.check_collision_point_rec(mouse_pos, action_rect):
|
||||
# Click was on right item, don't toggle description
|
||||
return
|
||||
|
||||
self._set_description_visible(not self.description_visible)
|
||||
|
||||
def _set_description_visible(self, visible: bool):
|
||||
if self.description and self.description_visible != visible:
|
||||
self.description_visible = visible
|
||||
# do callback first in case receiver changes description
|
||||
if self.description_visible and self.description_opened_callback is not None:
|
||||
self.description_opened_callback()
|
||||
# Call _update_state to catch any description changes
|
||||
self._update_state()
|
||||
|
||||
content_width = int(self._rect.width - ITEM_PADDING * 2)
|
||||
self._rect.height = self.get_item_height(self._font, content_width)
|
||||
|
||||
def _update_state(self):
|
||||
# Detect changes if description is callback
|
||||
new_description = self.description
|
||||
if new_description != self._prev_description:
|
||||
self._parse_description(new_description)
|
||||
|
||||
def _render(self, _):
|
||||
if not self.is_visible:
|
||||
return
|
||||
|
||||
# Don't draw items that are not in parent's viewport
|
||||
if ((self._rect.y + self.rect.height) <= self._parent_rect.y or
|
||||
self._rect.y >= (self._parent_rect.y + self._parent_rect.height)):
|
||||
return
|
||||
|
||||
content_x = self._rect.x + ITEM_PADDING
|
||||
text_x = content_x
|
||||
|
||||
# Only draw title and icon for items that have them
|
||||
if self.title:
|
||||
# Draw icon if present
|
||||
if self.icon:
|
||||
rl.draw_texture_ex(self._icon_texture, rl.Vector2(content_x, self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) / 2), 0.0, 1.0, rl.WHITE)
|
||||
text_x += ICON_SIZE + ITEM_PADDING
|
||||
|
||||
# Draw main text
|
||||
text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE)
|
||||
item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
|
||||
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
|
||||
|
||||
# Draw description if visible
|
||||
if self.description_visible:
|
||||
content_width = int(self._rect.width - ITEM_PADDING * 2)
|
||||
description_height = self._html_renderer.get_total_height(content_width)
|
||||
description_rect = rl.Rectangle(
|
||||
self._rect.x + ITEM_PADDING,
|
||||
self._rect.y + ITEM_DESC_V_OFFSET,
|
||||
content_width,
|
||||
description_height
|
||||
)
|
||||
self._html_renderer.render(description_rect)
|
||||
|
||||
# Draw right item if present
|
||||
if self.action_item:
|
||||
right_rect = self.get_right_item_rect(self._rect)
|
||||
right_rect.y = self._rect.y
|
||||
if self.action_item.render(right_rect) and self.action_item.enabled:
|
||||
# Right item was clicked/activated
|
||||
if self.callback:
|
||||
self.callback()
|
||||
|
||||
def set_icon(self, icon: str | None):
|
||||
self.icon = icon
|
||||
self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None
|
||||
|
||||
def set_description(self, description: str | Callable[[], str] | None):
|
||||
self._description = description
|
||||
|
||||
def _parse_description(self, new_desc):
|
||||
self._html_renderer.parse_html_content(new_desc)
|
||||
self._prev_description = new_desc
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return _resolve_value(self._title, "")
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return _resolve_value(self._description, "")
|
||||
|
||||
def get_item_height(self, font: rl.Font, max_width: int) -> float:
|
||||
if not self.is_visible:
|
||||
return 0
|
||||
|
||||
height = float(ITEM_BASE_HEIGHT)
|
||||
if self.description_visible:
|
||||
description_height = self._html_renderer.get_total_height(max_width)
|
||||
height += description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING
|
||||
return height
|
||||
|
||||
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
|
||||
if not self.action_item:
|
||||
return rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
right_width = self.action_item.get_width_hint()
|
||||
if right_width == 0: # Full width action (like DualButtonAction)
|
||||
return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y,
|
||||
item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT)
|
||||
|
||||
# Clip width to available space, never overlapping this Item's title
|
||||
content_width = item_rect.width - (ITEM_PADDING * 2)
|
||||
title_width = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE).x
|
||||
right_width = min(content_width - title_width, right_width)
|
||||
|
||||
right_x = item_rect.x + item_rect.width - right_width
|
||||
right_y = item_rect.y
|
||||
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
|
||||
|
||||
|
||||
# Factory functions
|
||||
def simple_item(title: str | Callable[[], str], callback: Callable | None = None) -> ListItem:
|
||||
return ListItem(title=title, callback=callback)
|
||||
|
||||
|
||||
def toggle_item(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
|
||||
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback)
|
||||
return ListItem(title=title, description=description, action_item=action, icon=icon)
|
||||
|
||||
|
||||
def button_item(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = ButtonAction(text=button_text, enabled=enabled)
|
||||
return ListItem(title=title, description=description, action_item=action, callback=callback)
|
||||
|
||||
|
||||
def text_item(title: str | Callable[[], str], value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
|
||||
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, enabled=enabled)
|
||||
return ListItem(title=title, description=description, action_item=action, callback=callback)
|
||||
|
||||
|
||||
def dual_button_item(left_text: str | Callable[[], str], right_text: str | Callable[[], str],
|
||||
left_callback: Callable | None = None, right_callback: Callable | None = None,
|
||||
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
|
||||
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
|
||||
return ListItem(title="", description=description, action_item=action)
|
||||
|
||||
|
||||
def multiple_button_item(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], selected_index: int,
|
||||
button_width: int = BUTTON_WIDTH, callback: Callable | None = None, icon: str = ""):
|
||||
action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback)
|
||||
return ListItem(title=title, description=description, icon=icon, action_item=action)
|
||||
@@ -0,0 +1,405 @@
|
||||
from enum import IntEnum
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
|
||||
CHAR_FONT_SIZE = 42
|
||||
CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2
|
||||
SELECTED_CHAR_FONT_SIZE = 128
|
||||
CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this
|
||||
NUMBER_LAYER_SWITCH_FONT_SIZE = 24
|
||||
KEYBOARD_COLUMN_PADDING = 33
|
||||
KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in
|
||||
|
||||
KEY_TOUCH_AREA_OFFSET = 10 # px
|
||||
KEY_DRAG_HYSTERESIS = 5 # px
|
||||
KEY_MIN_ANIMATION_TIME = 0.075 # s
|
||||
|
||||
DEBUG = False
|
||||
ANIMATION_SCALE = 0.65
|
||||
|
||||
|
||||
def zip_repeat(a, b):
|
||||
la, lb = len(a), len(b)
|
||||
for i in range(max(la, lb)):
|
||||
yield (a[i] if i < la else a[-1],
|
||||
b[i] if i < lb else b[-1])
|
||||
|
||||
|
||||
def fast_euclidean_distance(dx, dy):
|
||||
# https://en.wikibooks.org/wiki/Algorithms/Distance_approximations
|
||||
max_d, min_d = abs(dx), abs(dy)
|
||||
if max_d < min_d:
|
||||
max_d, min_d = min_d, max_d
|
||||
return 0.941246 * max_d + 0.41 * min_d
|
||||
|
||||
|
||||
class Key(Widget):
|
||||
def __init__(self, char: str, font_weight: FontWeight = FontWeight.SEMI_BOLD):
|
||||
super().__init__()
|
||||
self.char = char
|
||||
self._font = gui_app.font(font_weight)
|
||||
self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
|
||||
self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
|
||||
self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
|
||||
self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
|
||||
|
||||
self._color = rl.Color(255, 255, 255, 255)
|
||||
|
||||
self._position_initialized = False
|
||||
self.original_position = rl.Vector2(0, 0)
|
||||
|
||||
def set_position(self, x: float, y: float, smooth: bool = True):
|
||||
# Smooth keys within parent rect
|
||||
base_y = self._parent_rect.y if self._parent_rect else 0.0
|
||||
local_y = y - base_y
|
||||
|
||||
if not self._position_initialized:
|
||||
self._x_filter.x = x
|
||||
self._y_filter.x = local_y
|
||||
# keep track of original position so dragging around feels consistent. also move touch area down a bit
|
||||
self.original_position = rl.Vector2(x, local_y + KEY_TOUCH_AREA_OFFSET)
|
||||
self._position_initialized = True
|
||||
|
||||
if not smooth:
|
||||
self._x_filter.x = x
|
||||
self._y_filter.x = local_y
|
||||
|
||||
self._rect.x = self._x_filter.update(x)
|
||||
self._rect.y = base_y + self._y_filter.update(local_y)
|
||||
|
||||
def set_alpha(self, alpha: float):
|
||||
self._alpha_filter.update(alpha)
|
||||
|
||||
def get_position(self) -> tuple[float, float]:
|
||||
return self._rect.x, self._rect.y
|
||||
|
||||
def _update_state(self):
|
||||
self._color.a = min(int(255 * self._alpha_filter.x), 255)
|
||||
|
||||
def _render(self, _):
|
||||
# center char at rect position
|
||||
text_size = measure_text_cached(self._font, self.char, self._get_font_size())
|
||||
x = self._rect.x + self._rect.width / 2 - text_size.x / 2
|
||||
y = self._rect.y + self._rect.height / 2 - text_size.y / 2
|
||||
rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color)
|
||||
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
|
||||
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
|
||||
|
||||
def set_font_size(self, size: float):
|
||||
self._size_filter.update(size)
|
||||
|
||||
def _get_font_size(self) -> int:
|
||||
return round(self._size_filter.x)
|
||||
|
||||
|
||||
class SmallKey(Key):
|
||||
def __init__(self, chars: str):
|
||||
super().__init__(chars, FontWeight.BOLD)
|
||||
self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
|
||||
|
||||
def set_font_size(self, size: float):
|
||||
self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE))
|
||||
|
||||
|
||||
class IconKey(Key):
|
||||
def __init__(self, icon: str, vertical_align: str = "center", char: str = "", icon_size: tuple[int, int] = (38, 38)):
|
||||
super().__init__(char)
|
||||
self._icon_size = icon_size
|
||||
self._icon = gui_app.texture(icon, *icon_size)
|
||||
self._vertical_align = vertical_align
|
||||
|
||||
def set_icon(self, icon: str, icon_size: tuple[int, int] | None = None):
|
||||
size = icon_size if icon_size is not None else self._icon_size
|
||||
self._icon = gui_app.texture(icon, *size)
|
||||
|
||||
def _render(self, _):
|
||||
scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5])
|
||||
|
||||
if self._vertical_align == "center":
|
||||
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2,
|
||||
self._rect.y + (self._rect.height - self._icon.height * scale) / 2,
|
||||
self._icon.width * scale, self._icon.height * scale)
|
||||
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
|
||||
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
|
||||
|
||||
elif self._vertical_align == "bottom":
|
||||
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y,
|
||||
self._icon.width * scale, self._icon.height * scale)
|
||||
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
|
||||
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
|
||||
|
||||
if DEBUG:
|
||||
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
|
||||
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
|
||||
|
||||
|
||||
class CapsState(IntEnum):
|
||||
LOWER = 0
|
||||
UPPER = 1
|
||||
LOCK = 2
|
||||
|
||||
|
||||
class MiciKeyboard(Widget):
|
||||
def __init__(self, auto_return_to_letters: str = ""):
|
||||
super().__init__()
|
||||
self._auto_return_to_letters = auto_return_to_letters
|
||||
|
||||
lower_chars = [
|
||||
"qwertyuiop",
|
||||
"asdfghjkl",
|
||||
"zxcvbnm",
|
||||
]
|
||||
upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars]
|
||||
special_chars = [
|
||||
"1234567890",
|
||||
"-/:;()$&@\"",
|
||||
"~.,?!'#%",
|
||||
]
|
||||
super_special_chars = [
|
||||
"1234567890",
|
||||
"`[]{}^*+=_",
|
||||
"\\|<>¥€£•",
|
||||
]
|
||||
|
||||
self._lower_keys = [[Key(char) for char in row] for row in lower_chars]
|
||||
self._upper_keys = [[Key(char) for char in row] for row in upper_chars]
|
||||
self._special_keys = [[Key(char) for char in row] for row in special_chars]
|
||||
self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
|
||||
|
||||
# control keys
|
||||
self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom", icon_size=(43, 14))
|
||||
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png", icon_size=(38, 33))
|
||||
# these two are in different places on some layouts
|
||||
self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123")
|
||||
self._abc_key = SmallKey("abc")
|
||||
self._super_special_key = SmallKey("#+=")
|
||||
|
||||
# insert control keys
|
||||
for keys in (self._lower_keys, self._upper_keys):
|
||||
keys[2].insert(0, self._caps_key)
|
||||
keys[2].append(self._123_key)
|
||||
|
||||
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
|
||||
keys[1].append(self._space_key)
|
||||
|
||||
for keys in (self._special_keys, self._super_special_keys):
|
||||
keys[2].append(self._abc_key)
|
||||
|
||||
self._special_keys[2].insert(0, self._super_special_key)
|
||||
self._super_special_keys[2].insert(0, self._123_key2)
|
||||
|
||||
# set initial keys
|
||||
self._current_keys: list[list[Key]] = []
|
||||
self._set_keys(self._lower_keys)
|
||||
self._caps_state = CapsState.LOWER
|
||||
self._initialized = False
|
||||
|
||||
self._load_images()
|
||||
|
||||
self._closest_key: tuple[Key | None, float] = None, float('inf')
|
||||
self._selected_key_t: float | None = None # time key was initially selected
|
||||
self._unselect_key_t: float | None = None # time to unselect key after release
|
||||
self._dragging_on_keyboard = False
|
||||
|
||||
self._text: str = ""
|
||||
|
||||
self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
|
||||
self._selected_key_filter = FirstOrderFilter(0.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
|
||||
|
||||
def get_candidate_character(self) -> str:
|
||||
# return str of character about to be added to text
|
||||
key = self._closest_key[0]
|
||||
return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else ""
|
||||
|
||||
def get_keyboard_height(self) -> int:
|
||||
return int(self._txt_bg.height)
|
||||
|
||||
def _load_images(self):
|
||||
self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False)
|
||||
|
||||
def _set_keys(self, keys: list[list[Key]]):
|
||||
# inherit previous keys' positions to fix switching animation
|
||||
for current_row, row in zip(self._current_keys, keys, strict=False):
|
||||
# not all layouts have the same number of keys
|
||||
for current_key, key in zip_repeat(current_row, row):
|
||||
# reset parent rect for new keys
|
||||
key.set_parent_rect(self._rect)
|
||||
current_pos = current_key.get_position()
|
||||
key.set_position(current_pos[0], current_pos[1], smooth=False)
|
||||
|
||||
self._current_keys = keys
|
||||
|
||||
def set_text(self, text: str):
|
||||
self._text = text
|
||||
|
||||
def text(self) -> str:
|
||||
return self._text
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
keyboard_pos_y = self._rect.y + self._rect.height - self._txt_bg.height
|
||||
if mouse_event.left_pressed:
|
||||
if mouse_event.pos.y > keyboard_pos_y:
|
||||
self._dragging_on_keyboard = True
|
||||
elif mouse_event.left_released:
|
||||
self._dragging_on_keyboard = False
|
||||
|
||||
if mouse_event.left_down and self._dragging_on_keyboard:
|
||||
self._closest_key = self._get_closest_key()
|
||||
if self._selected_key_t is None:
|
||||
self._selected_key_t = rl.get_time()
|
||||
|
||||
# unselect key temporarily if mouse goes above keyboard
|
||||
if mouse_event.pos.y <= keyboard_pos_y:
|
||||
self._closest_key = (None, float('inf'))
|
||||
|
||||
if DEBUG:
|
||||
print('HANDLE MOUSE EVENT', mouse_event, self._closest_key[0].char if self._closest_key[0] else 'None')
|
||||
|
||||
def _get_closest_key(self) -> tuple[Key | None, float]:
|
||||
closest_key: tuple[Key | None, float] = (None, float('inf'))
|
||||
for row in self._current_keys:
|
||||
for key in row:
|
||||
mouse_pos = gui_app.last_mouse_event.pos
|
||||
# approximate distance for comparison is accurate enough
|
||||
# use local y coords so parent widget offset (e.g. during NavWidget animate-in) doesn't affect hit testing
|
||||
dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - (mouse_pos.y - self._rect.y))
|
||||
if dist < closest_key[1]:
|
||||
if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS:
|
||||
closest_key = (key, dist)
|
||||
return closest_key
|
||||
|
||||
def _set_uppercase(self, cycle: bool):
|
||||
self._set_keys(self._upper_keys if cycle else self._lower_keys)
|
||||
if not cycle:
|
||||
self._caps_state = CapsState.LOWER
|
||||
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png", icon_size=(38, 33))
|
||||
else:
|
||||
if self._caps_state == CapsState.LOWER:
|
||||
self._caps_state = CapsState.UPPER
|
||||
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png", icon_size=(38, 33))
|
||||
elif self._caps_state == CapsState.UPPER:
|
||||
self._caps_state = CapsState.LOCK
|
||||
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png", icon_size=(39, 38))
|
||||
else:
|
||||
self._set_uppercase(False)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if self._closest_key[0] is not None:
|
||||
if self._closest_key[0] == self._caps_key:
|
||||
self._set_uppercase(True)
|
||||
elif self._closest_key[0] in (self._123_key, self._123_key2):
|
||||
self._set_keys(self._special_keys)
|
||||
elif self._closest_key[0] == self._abc_key:
|
||||
self._set_uppercase(False)
|
||||
elif self._closest_key[0] == self._super_special_key:
|
||||
self._set_keys(self._super_special_keys)
|
||||
else:
|
||||
self._text += self._closest_key[0].char
|
||||
|
||||
# Reset caps state
|
||||
if self._caps_state == CapsState.UPPER:
|
||||
self._set_uppercase(False)
|
||||
|
||||
# Switch back to letters after common URL delimiters
|
||||
if self._closest_key[0].char in self._auto_return_to_letters and self._current_keys in (self._special_keys, self._super_special_keys):
|
||||
self._set_uppercase(False)
|
||||
|
||||
# ensure minimum selected animation time
|
||||
key_selected_dt = rl.get_time() - (self._selected_key_t or 0)
|
||||
cur_t = rl.get_time()
|
||||
self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t
|
||||
|
||||
def backspace(self):
|
||||
if self._text:
|
||||
self._text = self._text[:-1]
|
||||
|
||||
def space(self):
|
||||
self._text += ' '
|
||||
|
||||
def _update_state(self):
|
||||
# update selected key filter
|
||||
self._selected_key_filter.update(self._closest_key[0] is not None)
|
||||
|
||||
# unselect key after animation plays
|
||||
if (self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t) or not self.enabled:
|
||||
self._closest_key = (None, float('inf'))
|
||||
self._unselect_key_t = None
|
||||
self._selected_key_t = None
|
||||
|
||||
def _lay_out_keys(self, bg_x, bg_y, keys: list[list[Key]]):
|
||||
key_rect = rl.Rectangle(bg_x, bg_y, self._txt_bg.width, self._txt_bg.height)
|
||||
for row_idx, row in enumerate(keys):
|
||||
padding = KEYBOARD_ROW_PADDING[row_idx]
|
||||
step_y = (key_rect.height - 2 * KEYBOARD_COLUMN_PADDING) / (len(keys) - 1)
|
||||
for key_idx, key in enumerate(row):
|
||||
key_x = key_rect.x + padding + key_idx * ((key_rect.width - 2 * padding) / (len(row) - 1))
|
||||
key_y = key_rect.y + KEYBOARD_COLUMN_PADDING + row_idx * step_y
|
||||
|
||||
if self._closest_key[0] is None:
|
||||
key.set_alpha(1.0)
|
||||
key.set_font_size(CHAR_FONT_SIZE)
|
||||
elif key == self._closest_key[0]:
|
||||
# push key up with a max and inward so user can see key easier
|
||||
key_y = max(key_y - 120, 40)
|
||||
key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100])
|
||||
key.set_alpha(1.0)
|
||||
key.set_font_size(SELECTED_CHAR_FONT_SIZE)
|
||||
|
||||
# draw black circle behind selected key
|
||||
circle_alpha = int(self._selected_key_filter.x * 225)
|
||||
rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2),
|
||||
SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK)
|
||||
else:
|
||||
# move other keys away from selected key a bit
|
||||
dx = key.original_position.x - self._closest_key[0].original_position.x
|
||||
dy = key.original_position.y - self._closest_key[0].original_position.y
|
||||
distance_from_selected_key = fast_euclidean_distance(dx, dy)
|
||||
|
||||
inv = 1 / (distance_from_selected_key or 1.0)
|
||||
ux = dx * inv
|
||||
uy = dy * inv
|
||||
|
||||
# NOTE: hardcode to 20 to get entire keyboard to move
|
||||
push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0])
|
||||
key_x += ux * push_pixels
|
||||
key_y += uy * push_pixels
|
||||
|
||||
# TODO: slow enough to use an approximation or nah? also caching might work
|
||||
font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE])
|
||||
|
||||
key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35])
|
||||
key.set_alpha(key_alpha)
|
||||
key.set_font_size(font_size)
|
||||
|
||||
# TODO: I like the push amount, so we should clip the pos inside the keyboard rect
|
||||
key.set_parent_rect(self._rect)
|
||||
key.set_position(key_x, key_y)
|
||||
|
||||
def _render(self, _):
|
||||
# draw bg
|
||||
bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2
|
||||
bg_y = self._rect.y + self._rect.height - self._txt_bg.height
|
||||
|
||||
scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0)
|
||||
src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height)
|
||||
dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y,
|
||||
self._txt_bg.width * scale, self._txt_bg.height)
|
||||
|
||||
rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
|
||||
# draw keys
|
||||
if not self._initialized:
|
||||
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
|
||||
self._lay_out_keys(bg_x, bg_y, keys)
|
||||
self._initialized = True
|
||||
|
||||
self._lay_out_keys(bg_x, bg_y, self._current_keys)
|
||||
for row in self._current_keys:
|
||||
for key in row:
|
||||
key.render()
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent
|
||||
|
||||
SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
|
||||
START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
|
||||
BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
|
||||
|
||||
NAV_BAR_MARGIN = 6
|
||||
NAV_BAR_WIDTH = 205
|
||||
NAV_BAR_HEIGHT = 8
|
||||
|
||||
DISMISS_PUSH_OFFSET = NAV_BAR_MARGIN + NAV_BAR_HEIGHT + 50 # px extra to push down when dismissing
|
||||
DISMISS_ANIMATION_RC = 0.2 # slightly slower for non-user triggered dismiss animation
|
||||
|
||||
|
||||
class NavBar(Widget):
|
||||
FADE_AFTER_SECONDS = 2.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT))
|
||||
self._alpha = 1.0
|
||||
self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._fade_time = 0.0
|
||||
|
||||
def set_alpha(self, alpha: float) -> None:
|
||||
self._alpha = alpha
|
||||
self._fade_time = rl.get_time()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._alpha = 1.0
|
||||
self._alpha_filter.x = 1.0
|
||||
self._fade_time = rl.get_time()
|
||||
|
||||
def _render(self, _):
|
||||
if rl.get_time() - self._fade_time > self.FADE_AFTER_SECONDS:
|
||||
self._alpha = 0.0
|
||||
alpha = self._alpha_filter.update(self._alpha)
|
||||
|
||||
# white bar with black border
|
||||
rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha)))
|
||||
rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha)))
|
||||
|
||||
|
||||
class NavWidget(Widget, abc.ABC):
|
||||
"""
|
||||
A full screen widget that supports back navigation by swiping down from the top.
|
||||
"""
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 0.65
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# State
|
||||
self._drag_start_pos: MousePos | None = None # cleared after certain amount of horizontal movement
|
||||
self._dragging_down = False # swiped down enough to trigger dismissing on release
|
||||
self._playing_dismiss_animation = False # released and animating away
|
||||
self._y_pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
|
||||
|
||||
self._back_callback: Callable[[], None] | None = None # persistent callback for user-initiated back navigation
|
||||
self._dismiss_callback: Callable[[], None] | None = None # transient callback for programmatic dismiss
|
||||
# TODO: add this functionality to push_widget
|
||||
self._shown_callback: Callable[[], None] | None = None # transient callback fired after show animation completes
|
||||
|
||||
# TODO: move this state into NavBar
|
||||
self._nav_bar = self._child(NavBar())
|
||||
self._nav_bar_show_time = 0.0
|
||||
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
# Children can override this to block swipe away, like when not at
|
||||
# the top of a vertical scroll panel to prevent erroneous swipes
|
||||
return True
|
||||
|
||||
def set_back_callback(self, callback: Callable[[], None]) -> None:
|
||||
self._back_callback = callback
|
||||
|
||||
def set_shown_callback(self, callback: Callable[[], None] | None) -> None:
|
||||
self._shown_callback = callback
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
# Don't let touch events change filter state during dismiss animation
|
||||
if self._playing_dismiss_animation:
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
# user is able to swipe away if starting near top of screen
|
||||
self._y_pos_filter.update_alpha(0.04)
|
||||
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
|
||||
|
||||
if in_dismiss_area and self._back_enabled():
|
||||
self._drag_start_pos = mouse_event.pos
|
||||
|
||||
elif mouse_event.left_down:
|
||||
if self._drag_start_pos is not None:
|
||||
# block swiping away if too much horizontal or upward movement
|
||||
# block (lock-in) threshold is higher than start dismissing
|
||||
horizontal_movement = abs(mouse_event.pos.x - self._drag_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
|
||||
upward_movement = mouse_event.pos.y - self._drag_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
|
||||
|
||||
if not (horizontal_movement or upward_movement):
|
||||
# no blocking movement, check if we should start dismissing
|
||||
if mouse_event.pos.y - self._drag_start_pos.y > START_DISMISSING_THRESHOLD:
|
||||
self._dragging_down = True
|
||||
else:
|
||||
if not self._dragging_down:
|
||||
self._drag_start_pos = None
|
||||
|
||||
elif mouse_event.left_released:
|
||||
# reset rc for either slide up or down animation
|
||||
self._y_pos_filter.update_alpha(0.1)
|
||||
|
||||
# if far enough, trigger back navigation callback
|
||||
if self._drag_start_pos is not None:
|
||||
if mouse_event.pos.y - self._drag_start_pos.y > SWIPE_AWAY_THRESHOLD:
|
||||
self._playing_dismiss_animation = True
|
||||
|
||||
self._drag_start_pos = None
|
||||
self._dragging_down = False
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
new_y = 0.0
|
||||
|
||||
if self._dragging_down:
|
||||
self._nav_bar.set_alpha(1.0)
|
||||
|
||||
# FIXME: disabling this widget on new push_widget still causes this widget to track mouse events without mouse down
|
||||
if not self.enabled:
|
||||
self._drag_start_pos = None
|
||||
|
||||
if self._drag_start_pos is not None:
|
||||
last_mouse_event = gui_app.last_mouse_event
|
||||
# push entire widget as user drags it away
|
||||
new_y = max(last_mouse_event.pos.y - self._drag_start_pos.y, 0)
|
||||
if new_y < SWIPE_AWAY_THRESHOLD:
|
||||
new_y /= 2 # resistance until mouse release would dismiss widget
|
||||
|
||||
if self._playing_dismiss_animation:
|
||||
new_y = self._rect.height + DISMISS_PUSH_OFFSET
|
||||
|
||||
new_y = self._y_pos_filter.update(new_y)
|
||||
if abs(new_y) < 1 and abs(self._y_pos_filter.velocity.x) < 0.5:
|
||||
new_y = self._y_pos_filter.x = 0.0
|
||||
self._y_pos_filter.velocity.x = 0.0
|
||||
|
||||
if self._shown_callback is not None:
|
||||
self._shown_callback()
|
||||
self._shown_callback = None
|
||||
|
||||
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
|
||||
gui_app.pop_widget()
|
||||
|
||||
# Only one callback should ever be fired
|
||||
if self._dismiss_callback is not None:
|
||||
self._dismiss_callback()
|
||||
self._dismiss_callback = None
|
||||
elif self._back_callback is not None:
|
||||
self._back_callback()
|
||||
|
||||
self._playing_dismiss_animation = False
|
||||
self._drag_start_pos = None
|
||||
self._dragging_down = False
|
||||
|
||||
self.set_position(self._rect.x, new_y)
|
||||
|
||||
def _layout(self):
|
||||
# Dim whatever is behind this widget, fading with position (runs after _update_state so position is correct)
|
||||
overlay_alpha = int(200 * max(0.0, min(1.0, 1.0 - self._rect.y / self._rect.height))) if self._rect.height > 0 else 0
|
||||
rl.draw_rectangle_rec(rl.Rectangle(0, 0, self._rect.width, self._rect.height), rl.Color(0, 0, 0, overlay_alpha))
|
||||
|
||||
bounce_height = 20
|
||||
rl.draw_rectangle_rec(rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height + bounce_height), rl.BLACK)
|
||||
|
||||
def render(self, rect: rl.Rectangle | None = None) -> bool | int | None:
|
||||
ret = super().render(rect)
|
||||
|
||||
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
|
||||
nav_bar_delayed = rl.get_time() - self._nav_bar_show_time < 0.4
|
||||
# User dragging or dismissing, nav bar follows NavWidget
|
||||
if self._drag_start_pos is not None or self._playing_dismiss_animation:
|
||||
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._y_pos_filter.x
|
||||
# Waiting to show
|
||||
elif nav_bar_delayed:
|
||||
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
|
||||
# Animate back to top
|
||||
else:
|
||||
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
|
||||
|
||||
self._nav_bar.set_position(bar_x, self._nav_bar_y_filter.x)
|
||||
self._nav_bar.render()
|
||||
|
||||
return ret
|
||||
|
||||
@property
|
||||
def is_dismissing(self) -> bool:
|
||||
return self._dragging_down or self._playing_dismiss_animation
|
||||
|
||||
def dismiss(self, callback: Callable[[], None] | None = None):
|
||||
"""Programmatically trigger the dismiss animation. Calls pop_widget when done, then callback."""
|
||||
if not self._playing_dismiss_animation:
|
||||
self._playing_dismiss_animation = True
|
||||
self._y_pos_filter.update_alpha(DISMISS_ANIMATION_RC)
|
||||
self._dismiss_callback = callback
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
|
||||
# Reset state
|
||||
self._drag_start_pos = None
|
||||
self._dragging_down = False
|
||||
self._playing_dismiss_animation = False
|
||||
self._dismiss_callback = None
|
||||
# Start NavWidget off-screen, no matter how tall it is
|
||||
self._y_pos_filter.update_alpha(0.1)
|
||||
self._y_pos_filter.x = gui_app.height
|
||||
self._y_pos_filter.velocity.x = 0.0
|
||||
|
||||
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
|
||||
self._nav_bar_show_time = rl.get_time()
|
||||
@@ -0,0 +1,483 @@
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import cast
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType, normalize_ssid
|
||||
from openpilot.system.ui.widgets import DialogResult, Widget
|
||||
from openpilot.system.ui.widgets.button import ButtonStyle, Button
|
||||
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
|
||||
|
||||
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
|
||||
except Exception:
|
||||
Params = None
|
||||
ui_state = None
|
||||
PrimeType = None
|
||||
|
||||
NM_DEVICE_STATE_NEED_AUTH = 60
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 64
|
||||
ITEM_HEIGHT = 160
|
||||
ICON_SIZE = 50
|
||||
|
||||
STRENGTH_ICONS = [
|
||||
"icons/wifi_strength_low.png",
|
||||
"icons/wifi_strength_medium.png",
|
||||
"icons/wifi_strength_high.png",
|
||||
"icons/wifi_strength_full.png",
|
||||
]
|
||||
|
||||
|
||||
class PanelType(IntEnum):
|
||||
WIFI = 0
|
||||
ADVANCED = 1
|
||||
|
||||
|
||||
class UIState(IntEnum):
|
||||
IDLE = 0
|
||||
CONNECTING = 1
|
||||
NEEDS_AUTH = 2
|
||||
SHOW_FORGET_CONFIRM = 3
|
||||
FORGETTING = 4
|
||||
|
||||
|
||||
class NavButton(Widget):
|
||||
def __init__(self, text: str):
|
||||
super().__init__()
|
||||
self.text = text
|
||||
self.set_rect(rl.Rectangle(0, 0, 400, 100))
|
||||
|
||||
def _render(self, _):
|
||||
color = rl.Color(74, 74, 74, 255) if self.is_pressed else rl.Color(57, 57, 57, 255)
|
||||
rl.draw_rectangle_rounded(self._rect, 0.6, 10, color)
|
||||
gui_label(self.rect, self.text, font_size=60, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
|
||||
class NetworkUI(Widget):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self._current_panel: PanelType = PanelType.WIFI
|
||||
self._wifi_panel = self._child(WifiManagerUI(wifi_manager))
|
||||
self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager))
|
||||
self._nav_button = self._child(NavButton(tr("Advanced")))
|
||||
self._nav_button.set_click_callback(self._cycle_panel)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._set_current_panel(PanelType.WIFI)
|
||||
|
||||
def _cycle_panel(self):
|
||||
if self._current_panel == PanelType.WIFI:
|
||||
self._set_current_panel(PanelType.ADVANCED)
|
||||
else:
|
||||
self._set_current_panel(PanelType.WIFI)
|
||||
|
||||
def _render(self, _):
|
||||
# subtract button
|
||||
content_rect = rl.Rectangle(self._rect.x, self._rect.y + self._nav_button.rect.height + 40,
|
||||
self._rect.width, self._rect.height - self._nav_button.rect.height - 40)
|
||||
if self._current_panel == PanelType.WIFI:
|
||||
self._nav_button.text = tr("Advanced")
|
||||
self._nav_button.set_position(self._rect.x + self._rect.width - self._nav_button.rect.width, self._rect.y + 20)
|
||||
self._wifi_panel.render(content_rect)
|
||||
else:
|
||||
self._nav_button.text = tr("Back")
|
||||
self._nav_button.set_position(self._rect.x, self._rect.y + 20)
|
||||
self._advanced_panel.render(content_rect)
|
||||
|
||||
self._nav_button.render()
|
||||
|
||||
def _set_current_panel(self, panel: PanelType):
|
||||
self._current_panel = panel
|
||||
|
||||
|
||||
class AdvancedNetworkSettings(Widget):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
|
||||
self._params = Params()
|
||||
|
||||
self._keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
|
||||
|
||||
# Tethering
|
||||
self._tethering_action = ToggleAction(initial_state=False)
|
||||
tethering_btn = ListItem(lambda: tr("Enable Tethering"), action_item=self._tethering_action, callback=self._toggle_tethering)
|
||||
|
||||
# Edit tethering password
|
||||
self._tethering_password_action = ButtonAction(lambda: tr("EDIT"))
|
||||
tethering_password_btn = ListItem(lambda: tr("Tethering Password"), action_item=self._tethering_password_action, callback=self._edit_tethering_password)
|
||||
|
||||
# Roaming toggle
|
||||
roaming_enabled = self._params.get_bool("GsmRoaming")
|
||||
self._roaming_action = ToggleAction(initial_state=roaming_enabled)
|
||||
self._roaming_btn = ListItem(lambda: tr("Enable Roaming"), action_item=self._roaming_action, callback=self._toggle_roaming)
|
||||
|
||||
# Cellular metered toggle
|
||||
cellular_metered = self._params.get_bool("GsmMetered")
|
||||
self._cellular_metered_action = ToggleAction(initial_state=cellular_metered)
|
||||
self._cellular_metered_btn = ListItem(lambda: tr("Cellular Metered"),
|
||||
description=lambda: tr("Prevent large data uploads when on a metered cellular connection"),
|
||||
action_item=self._cellular_metered_action, callback=self._toggle_cellular_metered)
|
||||
|
||||
# APN setting
|
||||
self._apn_btn = button_item(lambda: tr("APN Setting"), lambda: tr("EDIT"), callback=self._edit_apn)
|
||||
|
||||
# Wi-Fi metered toggle
|
||||
self._wifi_metered_action = MultipleButtonAction([lambda: tr("default"), lambda: tr("metered"), lambda: tr("unmetered")], 255, 0,
|
||||
callback=self._toggle_wifi_metered)
|
||||
wifi_metered_btn = ListItem(lambda: tr("Wi-Fi Network Metered"), description=lambda: tr("Prevent large data uploads when on a metered Wi-Fi connection"),
|
||||
action_item=self._wifi_metered_action)
|
||||
|
||||
items: list[Widget] = [
|
||||
tethering_btn,
|
||||
tethering_password_btn,
|
||||
text_item(lambda: tr("IP Address"), lambda: self._wifi_manager.ipv4_address),
|
||||
self._roaming_btn,
|
||||
self._apn_btn,
|
||||
self._cellular_metered_btn,
|
||||
wifi_metered_btn,
|
||||
button_item(lambda: tr("Hidden Network"), lambda: tr("CONNECT"), callback=self._connect_to_hidden_network),
|
||||
]
|
||||
|
||||
self._scroller = Scroller(items, line_separator=True, spacing=0)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
self._tethering_action.set_enabled(True)
|
||||
self._tethering_action.set_state(self._wifi_manager.is_tethering_active())
|
||||
self._tethering_password_action.set_enabled(True)
|
||||
|
||||
if self._wifi_manager.is_tethering_active() or self._wifi_manager.ipv4_address == "":
|
||||
self._wifi_metered_action.set_enabled(False)
|
||||
self._wifi_metered_action.selected_button = 0
|
||||
elif self._wifi_manager.ipv4_address != "":
|
||||
metered = self._wifi_manager.current_network_metered
|
||||
self._wifi_metered_action.set_enabled(True)
|
||||
self._wifi_metered_action.selected_button = int(metered) if metered in (MeteredType.UNKNOWN, MeteredType.YES, MeteredType.NO) else 0
|
||||
|
||||
def _toggle_tethering(self):
|
||||
checked = self._tethering_action.get_state()
|
||||
self._tethering_action.set_enabled(False)
|
||||
if checked:
|
||||
self._wifi_metered_action.set_enabled(False)
|
||||
self._wifi_manager.set_tethering_active(checked)
|
||||
|
||||
def _toggle_roaming(self):
|
||||
self._params.put_bool("GsmRoaming", self._roaming_action.get_state(), block=True)
|
||||
|
||||
def _edit_apn(self):
|
||||
def update_apn(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
apn = self._keyboard.text.strip()
|
||||
if apn == "":
|
||||
self._params.remove("GsmApn")
|
||||
else:
|
||||
self._params.put("GsmApn", apn, block=True)
|
||||
|
||||
current_apn = self._params.get("GsmApn") or ""
|
||||
self._keyboard.reset(min_text_size=0)
|
||||
self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration"))
|
||||
self._keyboard.set_text(current_apn)
|
||||
self._keyboard.set_callback(update_apn)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _toggle_cellular_metered(self):
|
||||
self._params.put_bool("GsmMetered", self._cellular_metered_action.get_state(), block=True)
|
||||
|
||||
def _toggle_wifi_metered(self, metered):
|
||||
metered_type = {0: MeteredType.UNKNOWN, 1: MeteredType.YES, 2: MeteredType.NO}.get(metered, MeteredType.UNKNOWN)
|
||||
self._wifi_metered_action.set_enabled(False)
|
||||
self._wifi_manager.set_current_network_metered(metered_type)
|
||||
|
||||
def _connect_to_hidden_network(self):
|
||||
def connect_hidden(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
ssid = self._keyboard.text
|
||||
if not ssid:
|
||||
return
|
||||
|
||||
def enter_password(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
password = self._keyboard.text
|
||||
if password == "":
|
||||
# connect without password
|
||||
self._wifi_manager.connect_to_network(ssid, "", hidden=True)
|
||||
return
|
||||
|
||||
self._wifi_manager.connect_to_network(ssid, password, hidden=True)
|
||||
|
||||
self._keyboard.reset(min_text_size=0)
|
||||
self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid))
|
||||
self._keyboard.set_callback(enter_password)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
self._keyboard.reset(min_text_size=1)
|
||||
self._keyboard.set_title(tr("Enter SSID"), "")
|
||||
self._keyboard.set_callback(connect_hidden)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _edit_tethering_password(self):
|
||||
def update_password(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
password = self._keyboard.text
|
||||
self._wifi_manager.set_tethering_password(password)
|
||||
self._tethering_password_action.set_enabled(False)
|
||||
|
||||
self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
|
||||
self._keyboard.set_title(tr("Enter new tethering password"), "")
|
||||
self._keyboard.set_text(self._wifi_manager.tethering_password)
|
||||
self._keyboard.set_callback(update_password)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _update_state(self):
|
||||
self._wifi_manager.process_callbacks()
|
||||
|
||||
# If not using prime SIM, show GSM settings and enable IPv4 forwarding
|
||||
show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE)
|
||||
self._wifi_manager.set_ipv4_forward(show_cell_settings)
|
||||
self._roaming_btn.set_visible(show_cell_settings)
|
||||
self._apn_btn.set_visible(show_cell_settings)
|
||||
self._cellular_metered_btn.set_visible(show_cell_settings)
|
||||
|
||||
def _render(self, _):
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
class WifiManagerUI(Widget):
|
||||
def __init__(self, wifi_manager: WifiManager):
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self.state: UIState = UIState.IDLE
|
||||
self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING
|
||||
self._password_retry: bool = False # for NEEDS_AUTH
|
||||
self.btn_width: int = 200
|
||||
self.scroll_panel = GuiScrollPanel()
|
||||
self.keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
|
||||
self._load_icons()
|
||||
|
||||
self._networks: list[Network] = []
|
||||
self._networks_buttons: dict[str, Button] = {}
|
||||
self._forget_networks_buttons: dict[str, Button] = {}
|
||||
|
||||
self._wifi_manager.add_callbacks(need_auth=self._on_need_auth,
|
||||
activated=self._on_activated,
|
||||
forgotten=self._on_forgotten,
|
||||
networks_updated=self._on_network_updated,
|
||||
disconnected=self._on_disconnected)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# start/stop scanning when widget is visible
|
||||
self._wifi_manager.set_active(True)
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
self._wifi_manager.set_active(False)
|
||||
|
||||
def _load_icons(self):
|
||||
for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]:
|
||||
gui_app.texture(icon, ICON_SIZE, ICON_SIZE)
|
||||
|
||||
def _update_state(self):
|
||||
self._wifi_manager.process_callbacks()
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if not self._networks:
|
||||
gui_label(rect, tr("Scanning Wi-Fi networks..."), 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
return
|
||||
|
||||
if self.state == UIState.NEEDS_AUTH and self._state_network:
|
||||
self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"),
|
||||
tr("for \"{}\"").format(normalize_ssid(self._state_network.ssid)))
|
||||
self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
|
||||
self.keyboard.set_callback(lambda result: self._on_password_entered(cast(Network, self._state_network), result))
|
||||
gui_app.push_widget(self.keyboard)
|
||||
elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network:
|
||||
confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"), callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
|
||||
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(normalize_ssid(self._state_network.ssid)))
|
||||
gui_app.push_widget(confirm_dialog)
|
||||
else:
|
||||
self._draw_network_list(rect)
|
||||
|
||||
def _on_password_entered(self, network: Network, result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
password = self.keyboard.text
|
||||
self.keyboard.clear()
|
||||
|
||||
if len(password) >= MIN_PASSWORD_LENGTH:
|
||||
self.connect_to_network(network, password)
|
||||
elif result == DialogResult.CANCEL:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def on_forgot_confirm_finished(self, network, result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
self.forget_network(network)
|
||||
elif result == DialogResult.CANCEL:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def _draw_network_list(self, rect: rl.Rectangle):
|
||||
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT)
|
||||
offset = self.scroll_panel.update(rect, content_rect)
|
||||
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
for i, network in enumerate(self._networks):
|
||||
y_offset = rect.y + i * ITEM_HEIGHT + offset
|
||||
item_rect = rl.Rectangle(rect.x, y_offset, rect.width, ITEM_HEIGHT)
|
||||
if not rl.check_collision_recs(item_rect, rect):
|
||||
continue
|
||||
|
||||
self._draw_network_item(item_rect, network)
|
||||
if i < len(self._networks) - 1:
|
||||
line_y = int(item_rect.y + item_rect.height - 1)
|
||||
rl.draw_line(int(item_rect.x), int(line_y), int(item_rect.x + item_rect.width), line_y, rl.LIGHTGRAY)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_network_item(self, rect, network: Network):
|
||||
spacing = 50
|
||||
ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - self.btn_width * 2, ITEM_HEIGHT)
|
||||
signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
|
||||
security_icon_rect = rl.Rectangle(signal_icon_rect.x - spacing - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
|
||||
|
||||
status_text = ""
|
||||
if self.state == UIState.CONNECTING and self._state_network:
|
||||
if self._state_network.ssid == network.ssid:
|
||||
self._networks_buttons[network.ssid].set_enabled(False)
|
||||
status_text = tr("CONNECTING...")
|
||||
elif self.state == UIState.FORGETTING and self._state_network:
|
||||
if self._state_network.ssid == network.ssid:
|
||||
self._networks_buttons[network.ssid].set_enabled(False)
|
||||
status_text = tr("FORGETTING...")
|
||||
elif network.security_type == SecurityType.UNSUPPORTED:
|
||||
self._networks_buttons[network.ssid].set_enabled(False)
|
||||
else:
|
||||
self._networks_buttons[network.ssid].set_enabled(True)
|
||||
|
||||
self._networks_buttons[network.ssid].render(ssid_rect)
|
||||
|
||||
if status_text:
|
||||
status_text_rect = rl.Rectangle(security_icon_rect.x - 410, rect.y, 410, ITEM_HEIGHT)
|
||||
gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
else:
|
||||
# If the network is saved, show the "Forget" button
|
||||
if self._wifi_manager.is_connection_saved(network.ssid):
|
||||
forget_btn_rect = rl.Rectangle(
|
||||
security_icon_rect.x - self.btn_width - spacing,
|
||||
rect.y + (ITEM_HEIGHT - 80) / 2,
|
||||
self.btn_width,
|
||||
80,
|
||||
)
|
||||
self._forget_networks_buttons[network.ssid].render(forget_btn_rect)
|
||||
|
||||
self._draw_status_icon(security_icon_rect, network)
|
||||
self._draw_signal_strength_icon(signal_icon_rect, network)
|
||||
|
||||
def _networks_buttons_callback(self, network):
|
||||
if not self._wifi_manager.is_connection_saved(network.ssid) and network.security_type != SecurityType.OPEN:
|
||||
self.state = UIState.NEEDS_AUTH
|
||||
self._state_network = network
|
||||
self._password_retry = False
|
||||
elif self._wifi_manager.wifi_state.ssid != network.ssid:
|
||||
self.connect_to_network(network)
|
||||
|
||||
def _forget_networks_buttons_callback(self, network):
|
||||
self.state = UIState.SHOW_FORGET_CONFIRM
|
||||
self._state_network = network
|
||||
|
||||
def _draw_status_icon(self, rect, network: Network):
|
||||
"""Draw the status icon based on network's connection state"""
|
||||
icon_file = None
|
||||
if self._wifi_manager.connected_ssid == network.ssid and self.state != UIState.CONNECTING:
|
||||
icon_file = "icons/checkmark.png"
|
||||
elif network.security_type == SecurityType.UNSUPPORTED:
|
||||
icon_file = "icons/circled_slash.png"
|
||||
elif network.security_type != SecurityType.OPEN:
|
||||
icon_file = "icons/lock_closed.png"
|
||||
|
||||
if not icon_file:
|
||||
return
|
||||
|
||||
texture = gui_app.texture(icon_file, ICON_SIZE, ICON_SIZE)
|
||||
icon_rect = rl.Vector2(rect.x, rect.y + (ICON_SIZE - texture.height) / 2)
|
||||
rl.draw_texture_v(texture, icon_rect, rl.WHITE)
|
||||
|
||||
def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: Network):
|
||||
"""Draw the Wi-Fi signal strength icon based on network's signal strength"""
|
||||
strength_level = max(0, min(3, round(network.strength / 33.0)))
|
||||
rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE)
|
||||
|
||||
def connect_to_network(self, network: Network, password=''):
|
||||
self.state = UIState.CONNECTING
|
||||
self._state_network = network
|
||||
if self._wifi_manager.is_connection_saved(network.ssid) and not password:
|
||||
self._wifi_manager.activate_connection(network.ssid)
|
||||
else:
|
||||
self._wifi_manager.connect_to_network(network.ssid, password)
|
||||
|
||||
def forget_network(self, network: Network):
|
||||
self.state = UIState.FORGETTING
|
||||
self._state_network = network
|
||||
self._wifi_manager.forget_connection(network.ssid)
|
||||
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
self._networks = networks
|
||||
for n in self._networks:
|
||||
self._networks_buttons[n.ssid] = Button(normalize_ssid(n.ssid), partial(self._networks_buttons_callback, n), font_size=55,
|
||||
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
|
||||
self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
|
||||
self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI,
|
||||
font_size=45)
|
||||
self._forget_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
|
||||
|
||||
def _on_need_auth(self, ssid):
|
||||
network = next((n for n in self._networks if n.ssid == ssid), None)
|
||||
if network:
|
||||
self.state = UIState.NEEDS_AUTH
|
||||
self._state_network = network
|
||||
self._password_retry = True
|
||||
|
||||
def _on_activated(self):
|
||||
if self.state == UIState.CONNECTING:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def _on_forgotten(self, _):
|
||||
if self.state == UIState.FORGETTING:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def _on_disconnected(self):
|
||||
if self.state == UIState.CONNECTING:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
|
||||
def main():
|
||||
gui_app.init_window("Wi-Fi Manager")
|
||||
gui_app.push_widget(WifiManagerUI(WifiManager()))
|
||||
|
||||
for _ in gui_app.render():
|
||||
pass
|
||||
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget, DialogResult
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
# Constants
|
||||
MARGIN = 50
|
||||
TITLE_FONT_SIZE = 70
|
||||
ITEM_HEIGHT = 135
|
||||
BUTTON_SPACING = 50
|
||||
BUTTON_HEIGHT = 160
|
||||
ITEM_SPACING = 50
|
||||
LIST_ITEM_SPACING = 25
|
||||
|
||||
|
||||
class MultiOptionDialog(Widget):
|
||||
def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM, callback: Callable[[DialogResult], None] | None = None):
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self.options = options
|
||||
self.current = current
|
||||
self.selection = current
|
||||
self._callback = callback
|
||||
|
||||
# Create scroller with option buttons
|
||||
self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt),
|
||||
font_weight=option_font_weight,
|
||||
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL,
|
||||
text_padding=50, elide_right=True) for option in options]
|
||||
self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING)
|
||||
|
||||
self.cancel_button = Button(lambda: tr("Cancel"), click_callback=lambda: self._set_result(DialogResult.CANCEL))
|
||||
self.select_button = Button(lambda: tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY)
|
||||
|
||||
def _set_result(self, result: DialogResult):
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(result)
|
||||
|
||||
def _on_option_clicked(self, option):
|
||||
self.selection = option
|
||||
|
||||
def _render(self, rect):
|
||||
dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, rect.height - 2 * MARGIN)
|
||||
rl.draw_rectangle_rounded(dialog_rect, 0.02, 20, rl.Color(30, 30, 30, 255))
|
||||
|
||||
content_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN,
|
||||
dialog_rect.width - 2 * MARGIN, dialog_rect.height - 2 * MARGIN)
|
||||
|
||||
gui_label(rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, TITLE_FONT_SIZE), self.title, 70, font_weight=FontWeight.BOLD)
|
||||
|
||||
# Options area
|
||||
options_y = content_rect.y + TITLE_FONT_SIZE + ITEM_SPACING
|
||||
options_h = content_rect.height - TITLE_FONT_SIZE - BUTTON_HEIGHT - 2 * ITEM_SPACING
|
||||
options_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h)
|
||||
|
||||
# Update button styles and set width based on selection
|
||||
for i, option in enumerate(self.options):
|
||||
selected = option == self.selection
|
||||
button = self.option_buttons[i]
|
||||
button.set_button_style(ButtonStyle.PRIMARY if selected else ButtonStyle.NORMAL)
|
||||
button.set_rect(rl.Rectangle(0, 0, options_rect.width, ITEM_HEIGHT))
|
||||
|
||||
self.scroller.render(options_rect)
|
||||
|
||||
# Buttons
|
||||
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
|
||||
button_w = (content_rect.width - BUTTON_SPACING) / 2
|
||||
|
||||
cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT)
|
||||
self.cancel_button.render(cancel_rect)
|
||||
|
||||
select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT)
|
||||
self.select_button.set_enabled(self.selection != self.current)
|
||||
self.select_button.render(select_rect)
|
||||
@@ -0,0 +1,432 @@
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.nav_widget import NavWidget
|
||||
|
||||
ITEM_SPACING = 20
|
||||
LINE_COLOR = rl.GRAY
|
||||
LINE_PADDING = 40
|
||||
ANIMATION_SCALE = 0.6
|
||||
|
||||
MOVE_LIFT = 20
|
||||
MOVE_OVERLAY_ALPHA = 0.65
|
||||
SCROLL_RC = 0.15
|
||||
|
||||
EDGE_SHADOW_WIDTH = 20
|
||||
|
||||
MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
|
||||
DO_ZOOM = False
|
||||
DO_JELLO = False
|
||||
|
||||
|
||||
class ScrollIndicator(Widget):
|
||||
HORIZONTAL_MARGIN = 4
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/horizontal_scroll_indicator.png", 96, 48)
|
||||
self._scroll_offset: float = 0.0
|
||||
self._content_size: float = 0.0
|
||||
self._viewport: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
def update(self, scroll_offset: float, content_size: float, viewport: rl.Rectangle) -> None:
|
||||
self._scroll_offset = scroll_offset
|
||||
self._content_size = content_size
|
||||
self._viewport = viewport
|
||||
|
||||
def _render(self, _):
|
||||
# scale indicator width based on content size
|
||||
indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100]))
|
||||
|
||||
# position based on scroll ratio
|
||||
slide_range = self._viewport.width - indicator_w
|
||||
max_scroll = self._content_size - self._viewport.width
|
||||
scroll_ratio = (-self._scroll_offset / abs(max_scroll)) if abs(max_scroll) > 1e-3 else 0.0
|
||||
x = self._viewport.x + scroll_ratio * slide_range
|
||||
# don't bounce up when NavWidget shows
|
||||
y = max(self._viewport.y, 0) + self._viewport.height - self._txt_scroll_indicator.height / 2
|
||||
|
||||
# squeeze when overscrolling past edges
|
||||
dest_left = max(x, self._viewport.x)
|
||||
dest_right = min(x + indicator_w, self._viewport.x + self._viewport.width)
|
||||
dest_w = max(indicator_w / 2, dest_right - dest_left)
|
||||
|
||||
# keep within viewport after applying minimum width
|
||||
dest_left = min(dest_left, self._viewport.x + self._viewport.width - dest_w)
|
||||
dest_left = max(dest_left, self._viewport.x)
|
||||
|
||||
src_rec = rl.Rectangle(0, 0, self._txt_scroll_indicator.width, self._txt_scroll_indicator.height)
|
||||
dest_rec = rl.Rectangle(dest_left, y, dest_w, self._txt_scroll_indicator.height)
|
||||
rl.draw_texture_pro(self._txt_scroll_indicator, src_rec, dest_rec, rl.Vector2(0, 0), 0.0,
|
||||
rl.Color(255, 255, 255, int(255 * 0.45)))
|
||||
|
||||
|
||||
class _Scroller(Widget):
|
||||
"""Should use wrapper below to reduce boilerplate"""
|
||||
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING,
|
||||
pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True):
|
||||
super().__init__()
|
||||
self._items: list[Widget] = []
|
||||
self._horizontal = horizontal
|
||||
self._snap_items = snap_items
|
||||
assert not self._snap_items or self._horizontal, "Snapping is only supported for horizontal scrolling"
|
||||
self._spacing = spacing
|
||||
self._pad = pad
|
||||
|
||||
self._reset_scroll_at_show = True
|
||||
|
||||
self._scrolling_to: tuple[float | None, bool, bool] = (None, False, False) # target offset, block_interrupt, block_widget_interaction
|
||||
self._scrolling_to_filter = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps)
|
||||
self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
|
||||
self._zoom_out_t: float = 0.0
|
||||
|
||||
# layout state
|
||||
self._visible_items: list[Widget] = []
|
||||
self._content_size: float = 0.0
|
||||
self._scroll_offset: float = 0.0
|
||||
|
||||
self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self.scroll_panel = GuiScrollPanel2(self._horizontal)
|
||||
self._scroll_enabled: bool | Callable[[], bool] = True
|
||||
|
||||
self._show_scroll_indicator = scroll_indicator and self._horizontal
|
||||
self._scroll_indicator = ScrollIndicator()
|
||||
self._edge_shadows = edge_shadows and self._horizontal
|
||||
|
||||
# move animation state
|
||||
# on move; lift src widget -> wait -> move all -> wait -> drop src widget
|
||||
self._overlay_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
|
||||
self._move_animations: dict[Widget, FirstOrderFilter] = {}
|
||||
self._move_lift: dict[Widget, FirstOrderFilter] = {}
|
||||
# these are used to wait before moving/dropping, also to move onto next part of the animation earlier for timing
|
||||
self._pending_lift: set[Widget] = set()
|
||||
self._pending_move: set[Widget] = set()
|
||||
|
||||
self.add_widgets(items)
|
||||
|
||||
def set_reset_scroll_at_show(self, scroll: bool):
|
||||
self._reset_scroll_at_show = scroll
|
||||
|
||||
def scroll_to(self, pos: float, smooth: bool = False, block_interrupt: bool = False, block_widget_interaction: bool = False):
|
||||
assert smooth or (not block_interrupt and not block_widget_interaction), "Instant scroll cannot block interaction"
|
||||
|
||||
# already there
|
||||
if abs(pos) < 1:
|
||||
return
|
||||
|
||||
# FIXME: the padding correction doesn't seem correct
|
||||
scroll_offset = self.scroll_panel.get_offset() - pos
|
||||
if smooth:
|
||||
self._scrolling_to_filter.x = self.scroll_panel.get_offset()
|
||||
self._scrolling_to = scroll_offset, block_interrupt, block_widget_interaction
|
||||
else:
|
||||
self.scroll_panel.set_offset(scroll_offset)
|
||||
|
||||
@property
|
||||
def is_auto_scrolling(self) -> bool:
|
||||
return self._scrolling_to[0] is not None
|
||||
|
||||
@property
|
||||
def items(self) -> list[Widget]:
|
||||
return self._items
|
||||
|
||||
@property
|
||||
def content_size(self) -> float:
|
||||
return self._content_size
|
||||
|
||||
def add_widget(self, item: Widget) -> None:
|
||||
self._items.append(item)
|
||||
|
||||
# preserve original touch valid callback
|
||||
original_touch_valid_callback = item._touch_valid_callback
|
||||
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and not self._scrolling_to[2]
|
||||
and not self.moving_items and (original_touch_valid_callback() if
|
||||
original_touch_valid_callback else True))
|
||||
|
||||
def add_widgets(self, items: list[Widget]) -> None:
|
||||
for item in items:
|
||||
self.add_widget(item)
|
||||
|
||||
def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
|
||||
"""Set whether scrolling is enabled (does not affect widget enabled state)."""
|
||||
self._scroll_enabled = enabled
|
||||
|
||||
def _update_state(self):
|
||||
if DO_ZOOM:
|
||||
if self._scrolling_to[0] is not None or self.scroll_panel.state != ScrollState.STEADY:
|
||||
self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME
|
||||
self._zoom_filter.update(0.85)
|
||||
else:
|
||||
if self._zoom_out_t is not None:
|
||||
if rl.get_time() > self._zoom_out_t:
|
||||
self._zoom_filter.update(1.0)
|
||||
else:
|
||||
self._zoom_filter.update(0.85)
|
||||
|
||||
# Cancel auto-scroll if user starts manually scrolling (unless block_interaction)
|
||||
if (self.scroll_panel.state in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL) and
|
||||
self._scrolling_to[0] is not None and not self._scrolling_to[1]):
|
||||
self._scrolling_to = None, False, False
|
||||
|
||||
if self._scrolling_to[0] is not None and len(self._pending_lift) == 0:
|
||||
self._scrolling_to_filter.update(self._scrolling_to[0])
|
||||
self.scroll_panel.set_offset(self._scrolling_to_filter.x)
|
||||
|
||||
if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1: # finished scroll
|
||||
self.scroll_panel.set_offset(self._scrolling_to[0])
|
||||
self._scrolling_to = None, False, False
|
||||
|
||||
def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float:
|
||||
scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled
|
||||
self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1])
|
||||
|
||||
# Snap closest item to center. Skipped while scroll_to() is animating
|
||||
snap_target: float | None = None
|
||||
if self._snap_items and visible_items and self._scrolling_to[0] is None:
|
||||
# TODO: this doesn't handle two small buttons at the edges well
|
||||
center_pos = self._rect.x + self._rect.width / 2
|
||||
closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs)
|
||||
snap_target = self.scroll_panel.get_offset() - closest_delta_pos
|
||||
|
||||
return self.scroll_panel.update(self._rect, content_size, snap_target=snap_target)
|
||||
|
||||
@property
|
||||
def moving_items(self) -> bool:
|
||||
return len(self._move_animations) > 0 or len(self._move_lift) > 0
|
||||
|
||||
def move_item(self, from_idx: int, to_idx: int):
|
||||
assert self._horizontal
|
||||
if from_idx == to_idx:
|
||||
return
|
||||
|
||||
if self.moving_items:
|
||||
cloudlog.warning(f"Already moving items, cannot move from {from_idx} to {to_idx}")
|
||||
return
|
||||
|
||||
item = self._items.pop(from_idx)
|
||||
self._items.insert(to_idx, item)
|
||||
|
||||
# store original position in content space of all affected widgets to animate from
|
||||
for idx in range(min(from_idx, to_idx), max(from_idx, to_idx) + 1):
|
||||
affected_item = self._items[idx]
|
||||
self._move_animations[affected_item] = FirstOrderFilter(affected_item.rect.x - self._scroll_offset, SCROLL_RC, 1 / gui_app.target_fps)
|
||||
self._pending_move.add(affected_item)
|
||||
|
||||
# lift only src widget to make it more clear which one is moving
|
||||
self._move_lift[item] = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps)
|
||||
self._pending_lift.add(item)
|
||||
|
||||
def _do_move_animation(self, item: Widget, target_x: float, target_y: float) -> tuple[float, float]:
|
||||
# wait a frame before moving so we match potential pending scroll animation
|
||||
can_start_move = len(self._pending_lift) == 0
|
||||
|
||||
if item in self._move_lift:
|
||||
lift_filter = self._move_lift[item]
|
||||
|
||||
# Animate lift
|
||||
if len(self._pending_move) > 0:
|
||||
lift_filter.update(MOVE_LIFT)
|
||||
# start moving when almost lifted
|
||||
if abs(lift_filter.x - MOVE_LIFT) < 2:
|
||||
self._pending_lift.discard(item)
|
||||
else:
|
||||
# if done moving, animate down
|
||||
lift_filter.update(0)
|
||||
if abs(lift_filter.x) < 1:
|
||||
del self._move_lift[item]
|
||||
target_y -= lift_filter.x
|
||||
|
||||
# Animate move
|
||||
if item in self._move_animations:
|
||||
move_filter = self._move_animations[item]
|
||||
|
||||
# compare/update in content space to match filter
|
||||
content_x = target_x - self._scroll_offset
|
||||
if can_start_move:
|
||||
move_filter.update(content_x)
|
||||
|
||||
# drop when close to target
|
||||
if abs(move_filter.x - content_x) < 10:
|
||||
self._pending_move.discard(item)
|
||||
|
||||
# finished moving
|
||||
if abs(move_filter.x - content_x) < 1:
|
||||
del self._move_animations[item]
|
||||
target_x = move_filter.x + self._scroll_offset
|
||||
|
||||
return target_x, target_y
|
||||
|
||||
def _layout(self):
|
||||
self._visible_items = [item for item in self._items if item.is_visible]
|
||||
|
||||
self._content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in self._visible_items)
|
||||
self._content_size += self._spacing * (len(self._visible_items) - 1)
|
||||
self._content_size += self._pad * 2
|
||||
|
||||
self._scroll_offset = self._get_scroll(self._visible_items, self._content_size)
|
||||
|
||||
self._item_pos_filter.update(self._scroll_offset)
|
||||
|
||||
cur_pos = 0
|
||||
for idx, item in enumerate(self._visible_items):
|
||||
spacing = self._spacing if (idx > 0) else self._pad
|
||||
# Nicely lay out items horizontally/vertically
|
||||
if self._horizontal:
|
||||
x = self._rect.x + cur_pos + spacing
|
||||
y = self._rect.y + (self._rect.height - item.rect.height) / 2
|
||||
cur_pos += item.rect.width + spacing
|
||||
else:
|
||||
x = self._rect.x + (self._rect.width - item.rect.width) / 2
|
||||
y = self._rect.y + cur_pos + spacing
|
||||
cur_pos += item.rect.height + spacing
|
||||
|
||||
# Consider scroll
|
||||
if self._horizontal:
|
||||
x += self._scroll_offset
|
||||
else:
|
||||
y += self._scroll_offset
|
||||
|
||||
# Add some jello effect when scrolling
|
||||
if DO_JELLO:
|
||||
if self._horizontal:
|
||||
cx = self._rect.x + self._rect.width / 2
|
||||
jello_offset = self._scroll_offset - np.interp(x + item.rect.width / 2,
|
||||
[self._rect.x, cx, self._rect.x + self._rect.width],
|
||||
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
|
||||
x -= np.clip(jello_offset, -20, 20)
|
||||
else:
|
||||
cy = self._rect.y + self._rect.height / 2
|
||||
jello_offset = self._scroll_offset - np.interp(y + item.rect.height / 2,
|
||||
[self._rect.y, cy, self._rect.y + self._rect.height],
|
||||
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
|
||||
y -= np.clip(jello_offset, -20, 20)
|
||||
|
||||
# Animate moves if needed
|
||||
x, y = self._do_move_animation(item, x, y)
|
||||
|
||||
# Update item state
|
||||
item.set_position(x, y)
|
||||
item.set_parent_rect(self._rect)
|
||||
|
||||
def _render_item(self, item: Widget):
|
||||
# Skip rendering if not in viewport
|
||||
if not rl.check_collision_recs(item.rect, self._rect):
|
||||
return
|
||||
|
||||
# Scale each element around its own origin when scrolling
|
||||
scale = self._zoom_filter.x
|
||||
if scale != 1.0:
|
||||
rl.rl_push_matrix()
|
||||
rl.rl_scalef(scale, scale, 1.0)
|
||||
rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale,
|
||||
(1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0)
|
||||
item.render()
|
||||
rl.rl_pop_matrix()
|
||||
else:
|
||||
item.render()
|
||||
|
||||
def _render(self, _):
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
|
||||
int(self._rect.width), int(self._rect.height))
|
||||
|
||||
for item in reversed(self._visible_items):
|
||||
if item in self._move_lift:
|
||||
continue
|
||||
self._render_item(item)
|
||||
|
||||
# Dim background if moving items, lifted items are above
|
||||
self._overlay_filter.update(MOVE_OVERLAY_ALPHA if len(self._pending_move) else 0.0)
|
||||
if self._overlay_filter.x > 0.01:
|
||||
rl.draw_rectangle_rec(self._rect, rl.Color(0, 0, 0, int(255 * self._overlay_filter.x)))
|
||||
|
||||
for item in self._move_lift:
|
||||
self._render_item(item)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
# Draw edge shadows on top of scroller content
|
||||
if self._edge_shadows:
|
||||
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y),
|
||||
EDGE_SHADOW_WIDTH, int(self._rect.height),
|
||||
rl.Color(0, 0, 0, 204), rl.BLANK)
|
||||
|
||||
right_x = int(self._rect.x + self._rect.width - EDGE_SHADOW_WIDTH)
|
||||
rl.draw_rectangle_gradient_h(right_x, int(self._rect.y),
|
||||
EDGE_SHADOW_WIDTH, int(self._rect.height),
|
||||
rl.BLANK, rl.Color(0, 0, 0, 204))
|
||||
|
||||
# Draw scroll indicator on top of edge shadows
|
||||
if self._show_scroll_indicator and len(self._visible_items) > 0:
|
||||
self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect)
|
||||
self._scroll_indicator.render()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
for item in self._items:
|
||||
item.show_event()
|
||||
|
||||
if self._reset_scroll_at_show:
|
||||
self.scroll_panel.set_offset(0.0)
|
||||
|
||||
self._overlay_filter.x = 0.0
|
||||
self._move_animations.clear()
|
||||
self._move_lift.clear()
|
||||
self._pending_lift.clear()
|
||||
self._pending_move.clear()
|
||||
self._scrolling_to = None, False, False
|
||||
self._scrolling_to_filter.x = 0.0
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
for item in self._items:
|
||||
item.hide_event()
|
||||
|
||||
|
||||
class Scroller(Widget):
|
||||
"""Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack."""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__()
|
||||
self._scroller = self._child(_Scroller([], **kwargs))
|
||||
# pass down enabled to child widget for nav stack
|
||||
self._scroller.set_enabled(lambda: self.enabled)
|
||||
|
||||
def _render(self, _):
|
||||
self._scroller.render(self._rect)
|
||||
|
||||
|
||||
class NavScroller(NavWidget, Scroller):
|
||||
"""Full screen Scroller that properly supports nav stack w/ animations"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# pass down enabled to child widget for nav stack + disable while swiping away NavWidget
|
||||
self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
# Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes
|
||||
# TODO: only used for offroad alerts, remove when horizontal
|
||||
return self._scroller._horizontal or self._scroller.scroll_panel.get_offset() >= -20 # some tolerance
|
||||
|
||||
|
||||
# TODO: only used for a few vertical scrollers, remove when horizontal
|
||||
class NavRawScrollPanel(NavWidget):
|
||||
# can swipe anywhere, only when at top
|
||||
BACK_TOUCH_AREA_PERCENTAGE = 1.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._scroll_panel = GuiScrollPanel2(horizontal=False)
|
||||
self._scroll_panel.set_enabled(lambda: self.enabled and not self.is_dismissing)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._scroll_panel.set_offset(0)
|
||||
|
||||
def _back_enabled(self) -> bool:
|
||||
return self._scroll_panel.get_offset() >= -20
|
||||
@@ -0,0 +1,90 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
ITEM_SPACING = 40
|
||||
LINE_COLOR = rl.GRAY
|
||||
LINE_PADDING = 40
|
||||
|
||||
|
||||
class LineSeparator(Widget):
|
||||
def __init__(self, height: int = 1):
|
||||
super().__init__()
|
||||
self._rect = rl.Rectangle(0, 0, 0, height)
|
||||
|
||||
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
|
||||
super().set_parent_rect(parent_rect)
|
||||
self._rect.width = parent_rect.width
|
||||
|
||||
def _render(self, _):
|
||||
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
|
||||
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
|
||||
LINE_COLOR)
|
||||
|
||||
|
||||
class Scroller(Widget):
|
||||
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
|
||||
super().__init__()
|
||||
self._items: list[Widget] = []
|
||||
self._spacing = spacing
|
||||
self._line_separator = LineSeparator() if line_separator else None
|
||||
self._pad_end = pad_end
|
||||
|
||||
self.scroll_panel = GuiScrollPanel()
|
||||
|
||||
for item in items:
|
||||
self.add_widget(item)
|
||||
|
||||
def add_widget(self, item: Widget) -> None:
|
||||
self._items.append(item)
|
||||
item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
|
||||
|
||||
def _render(self, _):
|
||||
# TODO: don't draw items that are not in the viewport
|
||||
visible_items = [item for item in self._items if item.is_visible]
|
||||
|
||||
# Add line separator between items
|
||||
if self._line_separator is not None:
|
||||
l = len(visible_items)
|
||||
for i in range(1, len(visible_items)):
|
||||
visible_items.insert(l - i, self._line_separator)
|
||||
|
||||
content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
|
||||
if not self._pad_end:
|
||||
content_height -= self._spacing
|
||||
scroll = self.scroll_panel.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
|
||||
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
|
||||
int(self._rect.width), int(self._rect.height))
|
||||
|
||||
cur_height = 0
|
||||
for idx, item in enumerate(visible_items):
|
||||
if not item.is_visible:
|
||||
continue
|
||||
|
||||
# Nicely lay out items vertically
|
||||
x = self._rect.x
|
||||
y = self._rect.y + cur_height + self._spacing * (idx != 0)
|
||||
cur_height += item.rect.height + self._spacing * (idx != 0)
|
||||
|
||||
# Consider scroll
|
||||
y += scroll
|
||||
|
||||
# Update item state
|
||||
item.set_position(x, y)
|
||||
item.set_parent_rect(self._rect)
|
||||
item.render()
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# Reset to top
|
||||
self.scroll_panel.set_offset(0)
|
||||
for item in self._items:
|
||||
item.show_event()
|
||||
|
||||
def hide_event(self):
|
||||
super().hide_event()
|
||||
for item in self._items:
|
||||
item.hide_event()
|
||||
@@ -0,0 +1,204 @@
|
||||
import abc
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets.label import UnifiedLabel
|
||||
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
|
||||
|
||||
|
||||
class SliderBase(Widget, abc.ABC):
|
||||
HORIZONTAL_PADDING = 8
|
||||
CONFIRM_DELAY = 0.2
|
||||
PRESSED_SCALE = 1.07
|
||||
|
||||
_bg_txt: rl.Texture
|
||||
_circle_bg_txt: rl.Texture
|
||||
_circle_bg_pressed_txt: rl.Texture
|
||||
_circle_arrow_txt: rl.Texture
|
||||
|
||||
def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0):
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._shimmer_offset = shimmer_offset
|
||||
|
||||
self._load_assets()
|
||||
|
||||
self._drag_threshold = -self._rect.width // 2
|
||||
|
||||
# State
|
||||
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._confirmed_time = 0.0
|
||||
self._confirm_callback_called = False # we keep dialog open by default, only call once
|
||||
self._start_x_circle = 0.0
|
||||
self._scroll_x_circle = 0.0
|
||||
self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
self._circle_scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._circle_press_time: float | None = None
|
||||
|
||||
self._is_dragging_circle = False
|
||||
|
||||
self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True))
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load_assets(self):
|
||||
...
|
||||
|
||||
@property
|
||||
def confirmed(self) -> bool:
|
||||
return self._confirmed_time > 0.0
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
# reset all slider state
|
||||
self._is_dragging_circle = False
|
||||
self._circle_press_time = None
|
||||
self._confirmed_time = 0.0
|
||||
self._confirm_callback_called = False
|
||||
self._label.reset_shimmer(self._shimmer_offset)
|
||||
|
||||
def set_opacity(self, opacity: float, smooth: bool = False):
|
||||
if smooth:
|
||||
self._opacity_filter.update(opacity)
|
||||
else:
|
||||
self._opacity_filter.x = opacity
|
||||
|
||||
@property
|
||||
def slider_percentage(self):
|
||||
activated_pos = -self._bg_txt.width + self._circle_bg_txt.width
|
||||
return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0)
|
||||
|
||||
def _on_confirm(self):
|
||||
if self._confirm_callback:
|
||||
self._confirm_callback()
|
||||
|
||||
def _handle_mouse_event(self, mouse_event):
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
# touch rect goes to the padding
|
||||
circle_button_rect = rl.Rectangle(
|
||||
self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2,
|
||||
self._rect.y,
|
||||
self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2,
|
||||
self._rect.height,
|
||||
)
|
||||
if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
|
||||
self._start_x_circle = mouse_event.pos.x
|
||||
self._is_dragging_circle = True
|
||||
self._circle_press_time = rl.get_time()
|
||||
|
||||
elif mouse_event.left_released:
|
||||
# swiped to left
|
||||
if self._scroll_x_circle_filter.x < self._drag_threshold:
|
||||
self._confirmed_time = rl.get_time()
|
||||
|
||||
self._is_dragging_circle = False
|
||||
|
||||
if self._is_dragging_circle:
|
||||
self._scroll_x_circle = mouse_event.pos.x - self._start_x_circle
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
# TODO: this math can probably be cleaned up to remove duplicate stuff
|
||||
activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
|
||||
self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
|
||||
|
||||
if self.confirmed:
|
||||
# swiped left to confirm
|
||||
self._scroll_x_circle_filter.update(activated_pos)
|
||||
|
||||
# activate once animation completes, small threshold for small floats
|
||||
if self._scroll_x_circle_filter.x < (activated_pos + 1):
|
||||
if not self._confirm_callback_called and (rl.get_time() - self._confirmed_time) >= self.CONFIRM_DELAY:
|
||||
self._confirm_callback_called = True
|
||||
self._on_confirm()
|
||||
|
||||
elif not self._is_dragging_circle:
|
||||
# reset back to right
|
||||
self._scroll_x_circle_filter.update(0)
|
||||
else:
|
||||
# not activated yet, keep movement 1:1
|
||||
self._scroll_x_circle_filter.x = self._scroll_x_circle
|
||||
|
||||
def _render(self, _):
|
||||
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
|
||||
|
||||
bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
|
||||
bg_txt_y = self._rect.y + (self._rect.height - self._bg_txt.height) / 2
|
||||
rl.draw_texture_ex(self._bg_txt, rl.Vector2(bg_txt_x, bg_txt_y), 0.0, 1.0, white)
|
||||
|
||||
btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
|
||||
btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
|
||||
|
||||
label_alpha = int(255 * (1.0 - self.slider_percentage) * self._opacity_filter.x)
|
||||
if label_alpha > 0:
|
||||
self._label.set_text_color(rl.Color(255, 255, 255, label_alpha))
|
||||
label_rect = rl.Rectangle(
|
||||
self._rect.x + 20,
|
||||
self._rect.y,
|
||||
self._rect.width - self._circle_bg_txt.width - 20 * 2.5,
|
||||
self._rect.height,
|
||||
)
|
||||
self._label.render(label_rect)
|
||||
|
||||
# circle and arrow with grow animation
|
||||
circle_pressed = self._is_dragging_circle or self.confirmed or (self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075)
|
||||
circle_bg_txt = self._circle_bg_pressed_txt if circle_pressed else self._circle_bg_txt
|
||||
scale = self._circle_scale_filter.update(self.PRESSED_SCALE if circle_pressed else 1.0)
|
||||
scaled_btn_x = btn_x + (self._circle_bg_txt.width * (1 - scale)) / 2
|
||||
scaled_btn_y = btn_y + (self._circle_bg_txt.height * (1 - scale)) / 2
|
||||
rl.draw_texture_ex(circle_bg_txt, rl.Vector2(scaled_btn_x, scaled_btn_y), 0.0, scale, white)
|
||||
|
||||
arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
|
||||
arrow_y = scaled_btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2
|
||||
rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white)
|
||||
|
||||
|
||||
class LargerSlider(SliderBase):
|
||||
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0):
|
||||
self._green = green
|
||||
super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset)
|
||||
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
|
||||
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115)
|
||||
circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle"
|
||||
self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115)
|
||||
self._circle_bg_pressed_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}_pressed.png", 180, 115)
|
||||
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
|
||||
|
||||
|
||||
class BigSlider(SliderBase):
|
||||
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None):
|
||||
self._icon = icon
|
||||
super().__init__(title, confirm_callback=confirm_callback)
|
||||
self._label.set_font_size(48)
|
||||
self._label.set_font_weight(FontWeight.DISPLAY)
|
||||
self._label.set_line_height(0.875)
|
||||
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
|
||||
|
||||
self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
|
||||
self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
|
||||
self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", 180, 180)
|
||||
self._circle_arrow_txt = self._icon
|
||||
|
||||
|
||||
class RedBigSlider(BigSlider):
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
|
||||
|
||||
self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
|
||||
self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
|
||||
self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180)
|
||||
self._circle_arrow_txt = self._icon
|
||||
@@ -0,0 +1,81 @@
|
||||
import pyray as rl
|
||||
from collections.abc import Callable
|
||||
from openpilot.system.ui.lib.application import MousePos
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
|
||||
ON_COLOR = rl.Color(51, 171, 76, 255)
|
||||
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
|
||||
KNOB_COLOR = rl.WHITE
|
||||
DISABLED_ON_COLOR = rl.Color(0x22, 0x77, 0x22, 255) # Dark green when disabled + on
|
||||
DISABLED_OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
|
||||
DISABLED_KNOB_COLOR = rl.Color(0x88, 0x88, 0x88, 255)
|
||||
WIDTH, HEIGHT = 160, 80
|
||||
BG_HEIGHT = 60
|
||||
ANIMATION_SPEED = 8.0
|
||||
|
||||
|
||||
class Toggle(Widget):
|
||||
def __init__(self, initial_state: bool = False, callback: Callable[[bool], None] | None = None):
|
||||
super().__init__()
|
||||
self._state = initial_state
|
||||
self._callback = callback
|
||||
self._enabled = True
|
||||
self._progress = 1.0 if initial_state else 0.0
|
||||
self._target = self._progress
|
||||
self._clicked = False
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle):
|
||||
self._rect = rl.Rectangle(rect.x, rect.y, WIDTH, HEIGHT)
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
self._clicked = True
|
||||
self._state = not self._state
|
||||
self._target = 1.0 if self._state else 0.0
|
||||
if self._callback:
|
||||
self._callback(self._state)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._state
|
||||
|
||||
def set_state(self, state: bool):
|
||||
self._state = state
|
||||
self._target = 1.0 if state else 0.0
|
||||
|
||||
def is_enabled(self):
|
||||
return self._enabled
|
||||
|
||||
def update(self):
|
||||
if abs(self._progress - self._target) > 0.01:
|
||||
delta = rl.get_frame_time() * ANIMATION_SPEED
|
||||
self._progress += delta if self._progress < self._target else -delta
|
||||
self._progress = max(0.0, min(1.0, self._progress))
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self.update()
|
||||
|
||||
if self._enabled:
|
||||
bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress)
|
||||
knob_color = KNOB_COLOR
|
||||
else:
|
||||
bg_color = self._blend_color(DISABLED_OFF_COLOR, DISABLED_ON_COLOR, self._progress)
|
||||
knob_color = DISABLED_KNOB_COLOR
|
||||
|
||||
# Draw background
|
||||
bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
|
||||
|
||||
# Draw knob
|
||||
knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress
|
||||
knob_y = self._rect.y + HEIGHT / 2
|
||||
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
|
||||
|
||||
# TODO: use click callback
|
||||
clicked = self._clicked
|
||||
self._clicked = False
|
||||
return clicked
|
||||
|
||||
def _blend_color(self, c1, c2, t):
|
||||
return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255)
|
||||
Reference in New Issue
Block a user