mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-06-30 11:02:19 +08:00
UI
This commit is contained in:
+65
-238
@@ -1,18 +1,22 @@
|
||||
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.common.filter_simple import BounceFilter, FirstOrderFilter
|
||||
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()
|
||||
|
||||
device = Device() # type: ignore
|
||||
W = TypeVar('W', bound='Widget')
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
class DialogResult(IntEnum):
|
||||
@@ -25,23 +29,28 @@ 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._enabled: bool | Callable[[], bool] = True
|
||||
self._is_visible: bool | Callable[[], bool] = True
|
||||
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
|
||||
self._children: list = []
|
||||
|
||||
@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
|
||||
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()
|
||||
@@ -52,21 +61,8 @@ class Widget(abc.ABC):
|
||||
|
||||
@property
|
||||
def is_pressed(self) -> bool:
|
||||
return any(self.__is_pressed)
|
||||
|
||||
@property
|
||||
def _is_pressed(self) -> bool:
|
||||
return any(self.__is_pressed)
|
||||
|
||||
@_is_pressed.setter
|
||||
def _is_pressed(self, value: bool):
|
||||
if value:
|
||||
for i, tracked in enumerate(self._Widget__tracking_is_pressed):
|
||||
if tracked:
|
||||
self.__is_pressed[i] = True
|
||||
else:
|
||||
for i in range(len(self.__is_pressed)):
|
||||
self.__is_pressed[i] = False
|
||||
# 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:
|
||||
@@ -95,7 +91,7 @@ class Widget(abc.ABC):
|
||||
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
|
||||
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()
|
||||
@@ -107,26 +103,40 @@ class Widget(abc.ABC):
|
||||
return self._rect
|
||||
return rl.get_collision_rec(self._rect, self._parent_rect)
|
||||
|
||||
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
|
||||
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()
|
||||
@@ -186,6 +196,8 @@ class Widget(abc.ABC):
|
||||
|
||||
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()
|
||||
|
||||
@@ -193,225 +205,40 @@ class Widget(abc.ABC):
|
||||
"""Optionally handle mouse events. This is called before rendering."""
|
||||
# Default implementation does nothing, can be overridden by subclasses
|
||||
|
||||
def show_event(self):
|
||||
"""Optionally handle show event. Parent must manually call this"""
|
||||
for child in self._children:
|
||||
child.show_event()
|
||||
|
||||
def hide_event(self):
|
||||
"""Optionally handle hide event. Parent must manually call this"""
|
||||
for child in self._children:
|
||||
child.hide_event()
|
||||
|
||||
def _child(self, widget):
|
||||
"""Register a child widget for lifecycle propagation."""
|
||||
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):
|
||||
"""Dismiss this widget from the nav stack."""
|
||||
"""Immediately dismiss the widget, firing the callback after."""
|
||||
gui_app.pop_widget()
|
||||
if callback:
|
||||
callback()
|
||||
|
||||
|
||||
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 = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing
|
||||
DISMISS_TIME_SECONDS = 1.5
|
||||
|
||||
|
||||
class NavBar(Widget):
|
||||
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 > DISMISS_TIME_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__()
|
||||
self._back_callback: Callable[[], None] | None = None
|
||||
self._back_button_start_pos: MousePos | None = None
|
||||
self._swiping_away = False # currently swiping away
|
||||
self._can_swipe_away = True # swipe away is blocked after certain horizontal movement
|
||||
|
||||
self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
|
||||
self._playing_dismiss_animation = False
|
||||
self._trigger_animate_in = False
|
||||
self._back_enabled: bool | Callable[[], bool] = True
|
||||
self._nav_bar = NavBar()
|
||||
|
||||
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
self._set_up = False
|
||||
|
||||
@property
|
||||
def back_enabled(self) -> bool:
|
||||
return self._back_enabled() if callable(self._back_enabled) else self._back_enabled
|
||||
|
||||
def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None:
|
||||
self._back_enabled = enabled
|
||||
|
||||
def set_back_callback(self, callback: Callable[[], None]) -> None:
|
||||
self._back_callback = callback
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
if not self.back_enabled:
|
||||
self._back_button_start_pos = None
|
||||
self._swiping_away = False
|
||||
self._can_swipe_away = True
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
# user is able to swipe away if starting near top of screen, or anywhere if scroller is at top
|
||||
self._pos_filter.update_alpha(0.04)
|
||||
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
|
||||
|
||||
scroller_at_top = False
|
||||
vertical_scroller = False
|
||||
# TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
|
||||
if hasattr(self, '_scroller'):
|
||||
scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal
|
||||
vertical_scroller = not self._scroller._horizontal
|
||||
elif hasattr(self, '_scroll_panel'):
|
||||
scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
|
||||
vertical_scroller = not self._scroll_panel._horizontal
|
||||
|
||||
# Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes
|
||||
if (not vertical_scroller and in_dismiss_area) or scroller_at_top:
|
||||
self._can_swipe_away = True
|
||||
self._back_button_start_pos = mouse_event.pos
|
||||
|
||||
elif mouse_event.left_down:
|
||||
if self._back_button_start_pos is not None:
|
||||
# block swiping away if too much horizontal or upward movement
|
||||
horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
|
||||
upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
|
||||
if not self._swiping_away and (horizontal_movement or upward_movement):
|
||||
self._can_swipe_away = False
|
||||
self._back_button_start_pos = None
|
||||
|
||||
# block horizontal swiping if now swiping away
|
||||
if self._can_swipe_away:
|
||||
if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore
|
||||
self._swiping_away = True
|
||||
|
||||
elif mouse_event.left_released:
|
||||
self._pos_filter.update_alpha(0.1)
|
||||
# if far enough, trigger back navigation callback
|
||||
if self._back_button_start_pos is not None:
|
||||
if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD:
|
||||
self._playing_dismiss_animation = True
|
||||
|
||||
self._back_button_start_pos = None
|
||||
self._swiping_away = False
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
# Disable self's scroller while swiping away
|
||||
if not self._set_up:
|
||||
self._set_up = True
|
||||
if hasattr(self, '_scroller'):
|
||||
original_enabled = self._scroller._enabled
|
||||
self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
|
||||
elif hasattr(self, '_scroll_panel'):
|
||||
original_enabled = self._scroll_panel.enabled
|
||||
self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled))
|
||||
|
||||
if self._trigger_animate_in:
|
||||
self._pos_filter.x = self._rect.height
|
||||
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
|
||||
self._trigger_animate_in = False
|
||||
|
||||
new_y = 0.0
|
||||
|
||||
if self._back_button_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._back_button_start_pos.y, 0)
|
||||
if new_y < SWIPE_AWAY_THRESHOLD:
|
||||
new_y /= 2 # resistance until mouse release would dismiss widget
|
||||
|
||||
if self._swiping_away:
|
||||
self._nav_bar.set_alpha(1.0)
|
||||
|
||||
if self._playing_dismiss_animation:
|
||||
new_y = self._rect.height + DISMISS_PUSH_OFFSET
|
||||
|
||||
new_y = round(self._pos_filter.update(new_y))
|
||||
if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0:
|
||||
new_y = self._pos_filter.x = 0.0
|
||||
|
||||
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
|
||||
if self._back_callback is not None:
|
||||
self._back_callback()
|
||||
|
||||
self._playing_dismiss_animation = False
|
||||
self._back_button_start_pos = None
|
||||
self._swiping_away = False
|
||||
|
||||
self.set_position(self._rect.x, new_y)
|
||||
|
||||
def render(self, rect: rl.Rectangle = None) -> bool | int | None:
|
||||
ret = super().render(rect)
|
||||
|
||||
if self.back_enabled:
|
||||
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
|
||||
if self._back_button_start_pos is not None or self._playing_dismiss_animation:
|
||||
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x
|
||||
else:
|
||||
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
|
||||
|
||||
self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
|
||||
self._nav_bar.render()
|
||||
|
||||
# draw black above widget when dismissing
|
||||
if self._rect.y > 0:
|
||||
rl.draw_rectangle(int(self._rect.x), 0, int(self._rect.width), int(self._rect.y), rl.BLACK)
|
||||
|
||||
return ret
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
# FIXME: we don't know the height of the rect at first show_event since it's before the first render :(
|
||||
# so we need this hacky bool for now
|
||||
self._trigger_animate_in = True
|
||||
self._nav_bar.show_event()
|
||||
|
||||
@@ -5,7 +5,7 @@ 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, UnifiedLabel
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ class IconButton(Widget):
|
||||
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(self._texture, int(draw_x), int(draw_y), color)
|
||||
rl.draw_texture_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, color)
|
||||
|
||||
|
||||
class SmallCircleIconButton(Widget):
|
||||
@@ -219,85 +219,7 @@ class SmallCircleIconButton(Widget):
|
||||
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
|
||||
icon_white = white
|
||||
|
||||
rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), 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(self._icon_txt, int(icon_x), int(icon_y), icon_white)
|
||||
|
||||
|
||||
class SmallButton(Widget):
|
||||
def __init__(self, text: str):
|
||||
super().__init__()
|
||||
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
|
||||
|
||||
self._load_assets()
|
||||
|
||||
self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM,
|
||||
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
||||
|
||||
self._bg_disabled_txt = None
|
||||
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 194, 100))
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
|
||||
|
||||
def set_text(self, text: str):
|
||||
self._label.set_text(text)
|
||||
|
||||
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, _):
|
||||
if not self.enabled and self._bg_disabled_txt is not None:
|
||||
rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
|
||||
elif self.is_pressed:
|
||||
rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
|
||||
else:
|
||||
rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
|
||||
|
||||
opacity = 0.9 if self.enabled else 0.35
|
||||
self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
|
||||
self._label.render(self._rect)
|
||||
|
||||
|
||||
class SmallRedPillButton(SmallButton):
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 194, 100))
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
|
||||
|
||||
|
||||
class SmallerRoundedButton(SmallButton):
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 150, 100))
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
|
||||
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
|
||||
|
||||
|
||||
class WideRoundedButton(SmallButton):
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 316, 100))
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
|
||||
|
||||
|
||||
class WidishRoundedButton(SmallButton):
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 250, 100))
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
|
||||
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
|
||||
|
||||
|
||||
class FullRoundedButton(SmallButton):
|
||||
def _load_assets(self):
|
||||
self.set_rect(rl.Rectangle(0, 0, 520, 100))
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
|
||||
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)
|
||||
rl.draw_texture_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
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
|
||||
@@ -18,7 +18,7 @@ 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, on_close: Callable[[DialogResult], None] | None = None):
|
||||
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")
|
||||
@@ -27,8 +27,7 @@ class ConfirmDialog(Widget):
|
||||
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._dialog_result = DialogResult.NO_ACTION
|
||||
self._on_close = on_close
|
||||
self._callback = callback
|
||||
self._cancel_text = cancel_text
|
||||
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
|
||||
|
||||
@@ -38,17 +37,15 @@ class ConfirmDialog(Widget):
|
||||
else:
|
||||
self._html_renderer.parse_html_content(text)
|
||||
|
||||
def reset(self):
|
||||
self._dialog_result = DialogResult.NO_ACTION
|
||||
self._on_close = on_close
|
||||
|
||||
def _cancel_button_callback(self):
|
||||
self._dialog_result = DialogResult.CANCEL
|
||||
if self._on_close: self._on_close(self._dialog_result)
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CANCEL)
|
||||
|
||||
def _confirm_button_callback(self):
|
||||
self._dialog_result = DialogResult.CONFIRM
|
||||
if self._on_close: self._on_close(self._dialog_result)
|
||||
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
|
||||
@@ -78,11 +75,9 @@ class ConfirmDialog(Widget):
|
||||
self._scroller.render(text_rect)
|
||||
|
||||
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
||||
self._dialog_result = DialogResult.CONFIRM
|
||||
if self._on_close: self._on_close(self._dialog_result)
|
||||
self._confirm_button_callback()
|
||||
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
||||
self._dialog_result = DialogResult.CANCEL
|
||||
if self._on_close: self._on_close(self._dialog_result)
|
||||
self._cancel_button_callback()
|
||||
|
||||
if self._cancel_text:
|
||||
self._confirm_button.render(confirm_button)
|
||||
@@ -92,8 +87,6 @@ class ConfirmDialog(Widget):
|
||||
full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT)
|
||||
self._confirm_button.render(full_confirm_button)
|
||||
|
||||
return self._dialog_result
|
||||
|
||||
|
||||
def alert_dialog(message: str, button_text: str | None = None):
|
||||
if button_text is None:
|
||||
|
||||
@@ -260,7 +260,7 @@ class HtmlModal(Widget):
|
||||
super().__init__()
|
||||
self._content = HtmlRenderer(file_path=file_path, text=text)
|
||||
self._scroll_panel = GuiScrollPanel()
|
||||
self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY)
|
||||
self._ok_button = Button(tr("OK"), click_callback=gui_app.pop_widget, button_style=ButtonStyle.PRIMARY)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
margin = 50
|
||||
|
||||
@@ -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)
|
||||
@@ -1,43 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.system.ui.widgets import Widget, DialogResult
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
|
||||
|
||||
class InputDialog(Widget):
|
||||
def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None):
|
||||
super().__init__()
|
||||
self._default_text = default_text
|
||||
self._on_close = on_close
|
||||
self._dialog_result = DialogResult.NO_ACTION
|
||||
|
||||
self._keyboard = Keyboard(callback=self._on_keyboard_result)
|
||||
self._keyboard.set_title(title)
|
||||
self._keyboard.set_text(default_text)
|
||||
|
||||
def _on_keyboard_result(self, result: DialogResult):
|
||||
if self._dialog_result != DialogResult.NO_ACTION:
|
||||
return
|
||||
self._dialog_result = result
|
||||
if self._on_close:
|
||||
self._on_close(result, self._keyboard.text)
|
||||
|
||||
@property
|
||||
def result(self) -> DialogResult:
|
||||
return self._dialog_result
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._keyboard.text
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._dialog_result = DialogResult.NO_ACTION
|
||||
self._keyboard.show_event()
|
||||
self._keyboard.clear()
|
||||
if self._default_text:
|
||||
self._keyboard.set_text(self._default_text)
|
||||
|
||||
def _render(self, rect):
|
||||
self._keyboard.render(rect)
|
||||
return self._dialog_result
|
||||
@@ -7,7 +7,7 @@ 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 Widget, DialogResult
|
||||
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
|
||||
@@ -59,14 +59,8 @@ KEYBOARD_LAYOUTS = {
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
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
|
||||
@@ -86,9 +80,6 @@ class Keyboard(Widget):
|
||||
self._backspace_press_time: float = 0.0
|
||||
self._backspace_last_repeat: float = 0.0
|
||||
|
||||
self._render_return_status = -1
|
||||
self._first_render = False
|
||||
self._skip_input = False
|
||||
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
|
||||
|
||||
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
|
||||
@@ -109,18 +100,12 @@ class Keyboard(Widget):
|
||||
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,
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
@@ -142,39 +127,24 @@ class Keyboard(Widget):
|
||||
def set_callback(self, callback: Callable[[DialogResult], None] | None):
|
||||
self._callback = callback
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._skip_input = True
|
||||
|
||||
def _process_mouse_events(self):
|
||||
if not self._skip_input:
|
||||
super()._process_mouse_events()
|
||||
|
||||
def _cancel_button_callback(self):
|
||||
self.clear()
|
||||
if self in gui_app._nav_stack:
|
||||
gui_app.pop_widget()
|
||||
else:
|
||||
self._render_return_status = 0
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CANCEL)
|
||||
|
||||
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:
|
||||
if self in gui_app._nav_stack:
|
||||
gui_app.pop_widget()
|
||||
else:
|
||||
self._render_return_status = 1
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(DialogResult.CONFIRM)
|
||||
else:
|
||||
self.handle_key_press(k)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
self._skip_input = False
|
||||
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))
|
||||
@@ -236,8 +206,6 @@ class Keyboard(Widget):
|
||||
self._all_keys[key].set_enabled(is_enabled)
|
||||
self._all_keys[key].render(key_rect)
|
||||
|
||||
return self._render_return_status
|
||||
|
||||
def _render_input_area(self, input_rect: rl.Rectangle):
|
||||
if self._show_password_toggle:
|
||||
self._input_box.set_password_mode(self._password_mode)
|
||||
@@ -289,7 +257,6 @@ class Keyboard(Widget):
|
||||
def reset(self, min_text_size: int | None = None):
|
||||
if min_text_size is not None:
|
||||
self._min_text_size = min_text_size
|
||||
self._render_return_status = -1
|
||||
self._last_shift_press_time = 0
|
||||
self._backspace_pressed = False
|
||||
self._backspace_press_time = 0.0
|
||||
@@ -298,15 +265,18 @@ class Keyboard(Widget):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("Keyboard")
|
||||
keyboard = Keyboard(min_text_size=8, show_password_toggle=True)
|
||||
for _ in gui_app.render():
|
||||
keyboard.set_title("Keyboard Input", "Type your text below")
|
||||
result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
if result == 1:
|
||||
def callback(result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
print(f"You typed: {keyboard.text}")
|
||||
gui_app.request_close()
|
||||
elif result == 0:
|
||||
elif result == DialogResult.CANCEL:
|
||||
print("Canceled")
|
||||
gui_app.request_close()
|
||||
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()
|
||||
|
||||
+75
-202
@@ -27,166 +27,6 @@ class ScrollState(IntEnum):
|
||||
SCROLLING = 1
|
||||
|
||||
|
||||
# TODO: merge anything new here to master
|
||||
class MiciLabel(Widget):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
width: int = None,
|
||||
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_TOP,
|
||||
spacing: int = 0,
|
||||
line_height: int = None,
|
||||
elide_right: bool = True,
|
||||
wrap_text: bool = False,
|
||||
scroll: bool = False):
|
||||
super().__init__()
|
||||
self.text = text
|
||||
self.wrapped_text: list[str] = []
|
||||
self.font_size = font_size
|
||||
self.width = width
|
||||
self.color = color
|
||||
self.font_weight = font_weight
|
||||
self.alignment = alignment
|
||||
self.alignment_vertical = alignment_vertical
|
||||
self.spacing = spacing
|
||||
self.line_height = line_height if line_height is not None else font_size
|
||||
self.elide_right = elide_right
|
||||
self.wrap_text = wrap_text
|
||||
self._height = 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
|
||||
|
||||
assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
|
||||
assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
|
||||
|
||||
self.set_text(text)
|
||||
|
||||
@property
|
||||
def text_height(self):
|
||||
return self._height
|
||||
|
||||
def set_font_size(self, font_size: int):
|
||||
self.font_size = font_size
|
||||
self.set_text(self.text)
|
||||
|
||||
def set_width(self, width: int):
|
||||
self.width = width
|
||||
self._rect.width = width
|
||||
self.set_text(self.text)
|
||||
|
||||
def set_text(self, txt: str):
|
||||
self.text = txt
|
||||
text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
|
||||
if self.width is not None:
|
||||
self._rect.width = self.width
|
||||
else:
|
||||
self._rect.width = text_size.x
|
||||
|
||||
if self.wrap_text:
|
||||
self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
|
||||
self._height = len(self.wrapped_text) * self.line_height
|
||||
elif self.scroll:
|
||||
self._needs_scroll = self.scroll and text_size.x > self._rect.width
|
||||
self._rect.height = text_size.y
|
||||
|
||||
def set_color(self, color: rl.Color):
|
||||
self.color = color
|
||||
|
||||
def set_font_weight(self, font_weight: FontWeight):
|
||||
self.font_weight = font_weight
|
||||
self.set_text(self.text)
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
# Only scissor when we know there is a single scrolling line
|
||||
if self._needs_scroll:
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
|
||||
font = gui_app.font(self.font_weight)
|
||||
|
||||
text_y_offset = 0
|
||||
# Draw the text in the specified rectangle
|
||||
lines = self.wrapped_text or [self.text]
|
||||
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
|
||||
lines = lines[::-1]
|
||||
|
||||
for display_text in lines:
|
||||
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
|
||||
|
||||
# Elide text to fit within the rectangle
|
||||
if self.elide_right and text_size.x > rect.width:
|
||||
ellipsis = "..."
|
||||
left, right = 0, len(display_text)
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
candidate = display_text[:mid] + ellipsis
|
||||
candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
|
||||
if candidate_size.x <= rect.width:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
|
||||
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
|
||||
|
||||
# Handle scroll state
|
||||
elif self.scroll and 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 <= -text_size.x - self._rect.width / 3:
|
||||
self._scroll_offset = 0
|
||||
self._scroll_state = ScrollState.STARTING
|
||||
self._scroll_pause_t = None
|
||||
|
||||
# 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(self.alignment, 0) + self._scroll_offset
|
||||
|
||||
# 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(self.alignment_vertical, 0)
|
||||
text_y += text_y_offset
|
||||
|
||||
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), round(text_y)), self.font_size, self.spacing, self.color)
|
||||
# Draw 2nd instance for scrolling
|
||||
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
|
||||
text2_scroll_offset = text_size.x + self._rect.width / 3
|
||||
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), round(text_y)), self.font_size, self.spacing, self.color)
|
||||
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
|
||||
text_y_offset -= self.line_height
|
||||
else:
|
||||
text_y_offset += self.line_height
|
||||
|
||||
if self._needs_scroll:
|
||||
# draw black fade on left and right
|
||||
fade_width = 20
|
||||
rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
|
||||
if self._scroll_state != ScrollState.STARTING:
|
||||
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
|
||||
# TODO: This should be a Widget class
|
||||
def gui_label(
|
||||
rect: rl.Rectangle,
|
||||
@@ -233,7 +73,7 @@ def gui_label(
|
||||
|
||||
# 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(round(text_x), round(text_y)), font_size, 0, color)
|
||||
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
||||
|
||||
|
||||
def gui_text_box(
|
||||
@@ -393,7 +233,7 @@ class Label(Widget):
|
||||
|
||||
class UnifiedLabel(Widget):
|
||||
"""
|
||||
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
|
||||
Unified label widget that combines functionality from gui_label, gui_text_box, and Label.
|
||||
|
||||
Supports:
|
||||
- Emoji rendering
|
||||
@@ -402,11 +242,12 @@ class UnifiedLabel(Widget):
|
||||
- Proper multiline vertical alignment
|
||||
- Height calculation for layout purposes
|
||||
"""
|
||||
SHIMMER_BAND_WIDTH = 0.3
|
||||
SHIMMER_BLUR_RADIUS = 0.12
|
||||
SHIMMER_CYCLE_PERIOD = 2.5
|
||||
SHIMMER_SWEEP_FRACTION = 0.9
|
||||
SHIMMER_LOW_OPACITY = 0.65
|
||||
# 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],
|
||||
@@ -439,6 +280,8 @@ class UnifiedLabel(Widget):
|
||||
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
|
||||
|
||||
@@ -477,6 +320,14 @@ class UnifiedLabel(Widget):
|
||||
"""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
|
||||
@@ -504,7 +355,7 @@ class UnifiedLabel(Widget):
|
||||
new_line_height = line_height * 0.9
|
||||
if self._line_height != new_line_height:
|
||||
self._line_height = new_line_height
|
||||
self._cached_text = None
|
||||
self._cached_text = None # Invalidate cache (affects total height)
|
||||
|
||||
def set_font_weight(self, font_weight: FontWeight):
|
||||
"""Update the font weight."""
|
||||
@@ -527,7 +378,13 @@ class UnifiedLabel(Widget):
|
||||
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):
|
||||
@@ -647,25 +504,6 @@ class UnifiedLabel(Widget):
|
||||
return self._cached_total_height
|
||||
return 0.0
|
||||
|
||||
def _compute_shimmer_alpha(self, char_center_x: float, text_left: float, text_width: float) -> float:
|
||||
if text_width <= 0:
|
||||
return self.SHIMMER_LOW_OPACITY
|
||||
|
||||
elapsed = rl.get_time() - self._shimmer_start_time
|
||||
sigma = text_width * self.SHIMMER_BLUR_RADIUS
|
||||
|
||||
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)
|
||||
|
||||
margin = text_width * self.SHIMMER_BAND_WIDTH
|
||||
text_right = text_left + text_width
|
||||
center = text_right + margin - t * (text_width + 2.0 * margin)
|
||||
|
||||
d = char_center_x - center
|
||||
shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) if sigma > 0 else 0.0
|
||||
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
|
||||
|
||||
def _render(self, _):
|
||||
"""Render the label."""
|
||||
if self._rect.width <= 0 or self._rect.height <= 0:
|
||||
@@ -792,11 +630,34 @@ class UnifiedLabel(Widget):
|
||||
# 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)
|
||||
if self._scroll_state != ScrollState.STARTING:
|
||||
|
||||
# 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:
|
||||
@@ -809,21 +670,13 @@ class UnifiedLabel(Widget):
|
||||
line_x = self._rect.x + self._text_padding
|
||||
line_x += self._scroll_offset + x_offset
|
||||
|
||||
if self._shimmer and not emojis and line:
|
||||
base_alpha = self._text_color.a / 255.0
|
||||
text_width = max(size.x, 1.0)
|
||||
cursor_x = line_x
|
||||
for char in line:
|
||||
char_width = measure_text_cached(self._font, char, self._font_size, self._spacing_pixels).x
|
||||
char_center_x = cursor_x + char_width / 2.0
|
||||
shimmer_alpha = self._compute_shimmer_alpha(char_center_x, line_x, text_width)
|
||||
char_alpha = int(255 * base_alpha * shimmer_alpha)
|
||||
char_color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, char_alpha)
|
||||
rl.draw_text_ex(self._font, char, rl.Vector2(cursor_x, current_y), self._font_size, self._spacing_pixels, char_color)
|
||||
cursor_x += char_width
|
||||
return
|
||||
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)
|
||||
|
||||
# Render line with emojis
|
||||
def _render_line_normal(self, line, emojis, line_x, current_y):
|
||||
line_pos = rl.Vector2(line_x, current_y)
|
||||
prev_index = 0
|
||||
|
||||
@@ -847,3 +700,23 @@ class UnifiedLabel(Widget):
|
||||
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()
|
||||
+52
-1114
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,10 @@ def fast_euclidean_distance(dx, dy):
|
||||
|
||||
|
||||
class Key(Widget):
|
||||
def __init__(self, char: str):
|
||||
def __init__(self, char: str, font_weight: FontWeight = FontWeight.SEMI_BOLD):
|
||||
super().__init__()
|
||||
self.char = char
|
||||
self._font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
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)
|
||||
@@ -53,20 +53,23 @@ class Key(Widget):
|
||||
self.original_position = rl.Vector2(0, 0)
|
||||
|
||||
def set_position(self, x: float, y: float, smooth: bool = True):
|
||||
# TODO: swipe up from NavWidget has the keys lag behind other elements a bit
|
||||
# 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 = y
|
||||
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, y + KEY_TOUCH_AREA_OFFSET)
|
||||
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 = y
|
||||
self._y_filter.x = local_y
|
||||
|
||||
self._rect.x = self._x_filter.update(x)
|
||||
self._rect.y = self._y_filter.update(y)
|
||||
self._rect.y = base_y + self._y_filter.update(local_y)
|
||||
|
||||
def set_alpha(self, alpha: float):
|
||||
self._alpha_filter.update(alpha)
|
||||
@@ -92,12 +95,12 @@ class Key(Widget):
|
||||
self._size_filter.update(size)
|
||||
|
||||
def _get_font_size(self) -> int:
|
||||
return int(round(self._size_filter.x))
|
||||
return round(self._size_filter.x)
|
||||
|
||||
|
||||
class SmallKey(Key):
|
||||
def __init__(self, chars: str):
|
||||
super().__init__(chars)
|
||||
super().__init__(chars, FontWeight.BOLD)
|
||||
self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
|
||||
|
||||
def set_font_size(self, size: float):
|
||||
@@ -105,13 +108,15 @@ class SmallKey(Key):
|
||||
|
||||
|
||||
class IconKey(Key):
|
||||
def __init__(self, icon: str, vertical_align: str = "center", char: str = ""):
|
||||
def __init__(self, icon: str, vertical_align: str = "center", char: str = "", icon_size: tuple[int, int] = (38, 38)):
|
||||
super().__init__(char)
|
||||
self._icon = gui_app.texture(icon, 38, 38)
|
||||
self._icon_size = icon_size
|
||||
self._icon = gui_app.texture(icon, *icon_size)
|
||||
self._vertical_align = vertical_align
|
||||
|
||||
def set_icon(self, icon: str):
|
||||
self._icon = gui_app.texture(icon, 38, 38)
|
||||
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])
|
||||
@@ -141,8 +146,9 @@ class CapsState(IntEnum):
|
||||
|
||||
|
||||
class MiciKeyboard(Widget):
|
||||
def __init__(self):
|
||||
def __init__(self, auto_return_to_letters: str = ""):
|
||||
super().__init__()
|
||||
self._auto_return_to_letters = auto_return_to_letters
|
||||
|
||||
lower_chars = [
|
||||
"qwertyuiop",
|
||||
@@ -167,8 +173,8 @@ class MiciKeyboard(Widget):
|
||||
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")
|
||||
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png")
|
||||
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")
|
||||
@@ -222,6 +228,8 @@ class MiciKeyboard(Widget):
|
||||
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)
|
||||
|
||||
@@ -259,7 +267,8 @@ class MiciKeyboard(Widget):
|
||||
for key in row:
|
||||
mouse_pos = gui_app.last_mouse_event.pos
|
||||
# approximate distance for comparison is accurate enough
|
||||
dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y)
|
||||
# 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)
|
||||
@@ -269,14 +278,14 @@ class MiciKeyboard(Widget):
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png", icon_size=(39, 38))
|
||||
else:
|
||||
self._set_uppercase(False)
|
||||
|
||||
@@ -297,6 +306,10 @@ class MiciKeyboard(Widget):
|
||||
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()
|
||||
@@ -314,7 +327,7 @@ class MiciKeyboard(Widget):
|
||||
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:
|
||||
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
|
||||
@@ -365,6 +378,7 @@ class MiciKeyboard(Widget):
|
||||
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, _):
|
||||
|
||||
@@ -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()
|
||||
@@ -6,8 +6,8 @@ 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
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
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
|
||||
@@ -22,8 +22,8 @@ try:
|
||||
from openpilot.selfdrive.ui.lib.prime_state import PrimeType
|
||||
except Exception:
|
||||
Params = None
|
||||
ui_state = None # type: ignore
|
||||
PrimeType = None # type: ignore
|
||||
ui_state = None
|
||||
PrimeType = None
|
||||
|
||||
NM_DEVICE_STATE_NEED_AUTH = 60
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
@@ -69,17 +69,14 @@ class NetworkUI(Widget):
|
||||
super().__init__()
|
||||
self._wifi_manager = wifi_manager
|
||||
self._current_panel: PanelType = PanelType.WIFI
|
||||
self._wifi_panel = WifiManagerUI(wifi_manager)
|
||||
self._advanced_panel = AdvancedNetworkSettings(wifi_manager)
|
||||
self._nav_button = NavButton(tr("Advanced"))
|
||||
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)
|
||||
self._wifi_panel.show_event()
|
||||
|
||||
def hide_event(self):
|
||||
self._wifi_panel.hide_event()
|
||||
|
||||
def _cycle_panel(self):
|
||||
if self._current_panel == PanelType.WIFI:
|
||||
@@ -187,8 +184,8 @@ class AdvancedNetworkSettings(Widget):
|
||||
self._wifi_manager.update_gsm_settings(roaming_state, self._params.get("GsmApn") or "", self._params.get_bool("GsmMetered"))
|
||||
|
||||
def _edit_apn(self):
|
||||
def update_apn(result):
|
||||
if result != 1:
|
||||
def update_apn(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
apn = self._keyboard.text.strip()
|
||||
@@ -203,7 +200,8 @@ class AdvancedNetworkSettings(Widget):
|
||||
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)
|
||||
gui_app.set_modal_overlay(self._keyboard, update_apn)
|
||||
self._keyboard.set_callback(update_apn)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _toggle_cellular_metered(self):
|
||||
metered = self._cellular_metered_action.get_state()
|
||||
@@ -216,15 +214,18 @@ class AdvancedNetworkSettings(Widget):
|
||||
self._wifi_manager.set_current_network_metered(metered_type)
|
||||
|
||||
def _connect_to_hidden_network(self):
|
||||
def connect_hidden(result):
|
||||
if result != 1:
|
||||
def connect_hidden(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
ssid = self._keyboard.text
|
||||
if not ssid:
|
||||
return
|
||||
|
||||
def enter_password(result):
|
||||
def enter_password(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
password = self._keyboard.text
|
||||
if password == "":
|
||||
# connect without password
|
||||
@@ -235,15 +236,17 @@ class AdvancedNetworkSettings(Widget):
|
||||
|
||||
self._keyboard.reset(min_text_size=0)
|
||||
self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid))
|
||||
gui_app.set_modal_overlay(self._keyboard, enter_password)
|
||||
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"), "")
|
||||
gui_app.set_modal_overlay(self._keyboard, connect_hidden)
|
||||
self._keyboard.set_callback(connect_hidden)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _edit_tethering_password(self):
|
||||
def update_password(result):
|
||||
if result != 1:
|
||||
def update_password(result: DialogResult):
|
||||
if result != DialogResult.CONFIRM:
|
||||
return
|
||||
|
||||
password = self._keyboard.text
|
||||
@@ -253,7 +256,8 @@ class AdvancedNetworkSettings(Widget):
|
||||
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)
|
||||
gui_app.set_modal_overlay(self._keyboard, update_password)
|
||||
self._keyboard.set_callback(update_password)
|
||||
gui_app.push_widget(self._keyboard)
|
||||
|
||||
def _update_state(self):
|
||||
self._wifi_manager.process_callbacks()
|
||||
@@ -292,10 +296,12 @@ class WifiManagerUI(Widget):
|
||||
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):
|
||||
@@ -311,31 +317,32 @@ class WifiManagerUI(Widget):
|
||||
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(self._state_network.ssid))
|
||||
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)
|
||||
gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result))
|
||||
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"))
|
||||
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid))
|
||||
confirm_dialog.reset()
|
||||
gui_app.set_modal_overlay(confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
|
||||
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: int):
|
||||
if result == 1:
|
||||
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 == 0:
|
||||
elif result == DialogResult.CANCEL:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def on_forgot_confirm_finished(self, network, result: int):
|
||||
if result == 1:
|
||||
def on_forgot_confirm_finished(self, network, result: DialogResult):
|
||||
if result == DialogResult.CONFIRM:
|
||||
self.forget_network(network)
|
||||
elif result == 0:
|
||||
elif result == DialogResult.CANCEL:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def _draw_network_list(self, rect: rl.Rectangle):
|
||||
@@ -383,7 +390,7 @@ class WifiManagerUI(Widget):
|
||||
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 network.is_saved:
|
||||
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,
|
||||
@@ -396,11 +403,11 @@ class WifiManagerUI(Widget):
|
||||
self._draw_signal_strength_icon(signal_icon_rect, network)
|
||||
|
||||
def _networks_buttons_callback(self, network):
|
||||
if not network.is_saved and network.security_type != SecurityType.OPEN:
|
||||
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 not network.is_connected:
|
||||
elif self._wifi_manager.wifi_state.ssid != network.ssid:
|
||||
self.connect_to_network(network)
|
||||
|
||||
def _forget_networks_buttons_callback(self, network):
|
||||
@@ -410,7 +417,7 @@ class WifiManagerUI(Widget):
|
||||
def _draw_status_icon(self, rect, network: Network):
|
||||
"""Draw the status icon based on network's connection state"""
|
||||
icon_file = None
|
||||
if network.is_connected and self.state != UIState.CONNECTING:
|
||||
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"
|
||||
@@ -432,7 +439,7 @@ class WifiManagerUI(Widget):
|
||||
def connect_to_network(self, network: Network, password=''):
|
||||
self.state = UIState.CONNECTING
|
||||
self._state_network = network
|
||||
if network.is_saved and not password:
|
||||
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)
|
||||
@@ -445,7 +452,7 @@ class WifiManagerUI(Widget):
|
||||
def _on_network_updated(self, networks: list[Network]):
|
||||
self._networks = networks
|
||||
for n in self._networks:
|
||||
self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55,
|
||||
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,
|
||||
@@ -463,7 +470,7 @@ class WifiManagerUI(Widget):
|
||||
if self.state == UIState.CONNECTING:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
def _on_forgotten(self):
|
||||
def _on_forgotten(self, _):
|
||||
if self.state == UIState.FORGETTING:
|
||||
self.state = UIState.IDLE
|
||||
|
||||
@@ -474,10 +481,10 @@ class WifiManagerUI(Widget):
|
||||
|
||||
def main():
|
||||
gui_app.init_window("Wi-Fi Manager")
|
||||
wifi_ui = WifiManagerUI(WifiManager())
|
||||
gui_app.push_widget(WifiManagerUI(WifiManager()))
|
||||
|
||||
for _ in gui_app.render():
|
||||
wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100))
|
||||
pass
|
||||
|
||||
gui_app.close()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import FontWeight
|
||||
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
|
||||
@@ -17,13 +18,13 @@ LIST_ITEM_SPACING = 25
|
||||
|
||||
|
||||
class MultiOptionDialog(Widget):
|
||||
def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM):
|
||||
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._result: DialogResult = DialogResult.NO_ACTION
|
||||
self._callback = callback
|
||||
|
||||
# Create scroller with option buttons
|
||||
self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt),
|
||||
@@ -36,7 +37,9 @@ class MultiOptionDialog(Widget):
|
||||
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):
|
||||
self._result = result
|
||||
gui_app.pop_widget()
|
||||
if self._callback:
|
||||
self._callback(result)
|
||||
|
||||
def _on_option_clicked(self, option):
|
||||
self.selection = option
|
||||
@@ -74,5 +77,3 @@ class MultiOptionDialog(Widget):
|
||||
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)
|
||||
|
||||
return self._result
|
||||
|
||||
+228
-102
@@ -3,38 +3,31 @@ 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
|
||||
SCROLL_BAR = False
|
||||
|
||||
|
||||
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 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)
|
||||
@@ -48,23 +41,23 @@ class ScrollIndicator(Widget):
|
||||
self._viewport = viewport
|
||||
|
||||
def _render(self, _):
|
||||
if self._viewport.width <= 0 or self._viewport.height <= 0:
|
||||
return
|
||||
# scale indicator width based on content size
|
||||
indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100]))
|
||||
|
||||
indicator_w = min(float(np.interp(self._content_size, [1000, 3000], [300, 100])), self._viewport.width)
|
||||
# position based on scroll ratio
|
||||
slide_range = self._viewport.width - indicator_w
|
||||
max_scroll = self._content_size - self._viewport.width
|
||||
if max_scroll > 0:
|
||||
scroll_ratio = -self._scroll_offset / max_scroll
|
||||
slide_range = max(self._viewport.width - indicator_w, 0.0)
|
||||
x = self._viewport.x + scroll_ratio * slide_range
|
||||
else:
|
||||
x = self._viewport.x + (self._viewport.width - indicator_w) / 2
|
||||
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)
|
||||
|
||||
@@ -74,23 +67,21 @@ class ScrollIndicator(Widget):
|
||||
rl.Color(255, 255, 255, int(255 * 0.45)))
|
||||
|
||||
|
||||
class Scroller(Widget):
|
||||
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING,
|
||||
line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING,
|
||||
scroll_indicator: bool = False, edge_shadows: bool = False):
|
||||
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
|
||||
self._spacing = spacing
|
||||
self._line_separator = LineSeparator() if line_separator else None
|
||||
self._pad_start = pad_start
|
||||
self._pad_end = pad_end
|
||||
self._pad = pad
|
||||
|
||||
self._reset_scroll_at_show = True
|
||||
|
||||
self._scrolling_to: float | None = None
|
||||
self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
||||
self._scrolling_to: tuple[float | None, bool] = (None, False) # target offset, block_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
|
||||
|
||||
@@ -107,22 +98,27 @@ class Scroller(Widget):
|
||||
self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
|
||||
self._scroll_enabled: bool | Callable[[], bool] = True
|
||||
|
||||
self._txt_vertical_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80)
|
||||
self._show_scroll_indicator = scroll_indicator and self._horizontal
|
||||
self._scroll_indicator = ScrollIndicator()
|
||||
self._edge_shadows = edge_shadows and self._horizontal
|
||||
|
||||
for item in items:
|
||||
self.add_widget(item)
|
||||
# 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()
|
||||
|
||||
@property
|
||||
def items(self) -> list[Widget]:
|
||||
return self._items
|
||||
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):
|
||||
def scroll_to(self, pos: float, smooth: bool = False, block_interaction: bool = False):
|
||||
assert not block_interaction or smooth, "Instant scroll cannot block user interaction"
|
||||
|
||||
# already there
|
||||
if abs(pos) < 1:
|
||||
return
|
||||
@@ -130,25 +126,35 @@ class Scroller(Widget):
|
||||
# FIXME: the padding correction doesn't seem correct
|
||||
scroll_offset = self.scroll_panel.get_offset() - pos
|
||||
if smooth:
|
||||
self._scrolling_to = scroll_offset
|
||||
self._scrolling_to_filter.x = self.scroll_panel.get_offset()
|
||||
self._scrolling_to = scroll_offset, block_interaction
|
||||
else:
|
||||
self.scroll_panel.set_offset(scroll_offset)
|
||||
|
||||
@property
|
||||
def is_auto_scrolling(self) -> bool:
|
||||
return self._scrolling_to is not None
|
||||
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)
|
||||
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
|
||||
|
||||
def move_item(self, from_index: int, to_index: int) -> None:
|
||||
if from_index == to_index:
|
||||
return
|
||||
if not (0 <= from_index < len(self._items) and 0 <= to_index < len(self._items)):
|
||||
return
|
||||
item = self._items.pop(from_index)
|
||||
self._items.insert(to_index, 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 self._scrolling_to[0] is None
|
||||
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)."""
|
||||
@@ -156,7 +162,7 @@ class Scroller(Widget):
|
||||
|
||||
def _update_state(self):
|
||||
if DO_ZOOM:
|
||||
if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY:
|
||||
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:
|
||||
@@ -166,27 +172,25 @@ class Scroller(Widget):
|
||||
else:
|
||||
self._zoom_filter.update(0.85)
|
||||
|
||||
# Cancel auto-scroll if user starts manually scrolling
|
||||
if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL):
|
||||
self._scrolling_to = None
|
||||
# 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
|
||||
|
||||
if self._scrolling_to is not None:
|
||||
self._scroll_filter.update(self._scrolling_to)
|
||||
self.scroll_panel.set_offset(self._scroll_filter.x)
|
||||
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._scroll_filter.x - self._scrolling_to) < 1:
|
||||
self.scroll_panel.set_offset(self._scrolling_to)
|
||||
self._scrolling_to = None
|
||||
else:
|
||||
# keep current scroll position up to date
|
||||
self._scroll_filter.x = self.scroll_panel.get_offset()
|
||||
if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1:
|
||||
self.scroll_panel.set_offset(self._scrolling_to[0])
|
||||
self._scrolling_to = None, 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)
|
||||
self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1])
|
||||
self.scroll_panel.update(self._rect, content_size)
|
||||
if not self._snap_items:
|
||||
return round(self.scroll_panel.get_offset())
|
||||
return self.scroll_panel.get_offset()
|
||||
|
||||
# Snap closest item to center
|
||||
center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2
|
||||
@@ -222,29 +226,86 @@ class Scroller(Widget):
|
||||
|
||||
return self.scroll_panel.get_offset()
|
||||
|
||||
@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]
|
||||
|
||||
# Add line separator between items
|
||||
if self._line_separator is not None:
|
||||
l = len(self._visible_items)
|
||||
for i in range(1, len(self._visible_items)):
|
||||
self._visible_items.insert(l - i, self._line_separator)
|
||||
|
||||
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_start + self._pad_end
|
||||
self._content_size += self._pad * 2
|
||||
|
||||
self._scroll_offset = self._get_scroll(self._visible_items, self._content_size)
|
||||
|
||||
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
|
||||
int(self._rect.width), int(self._rect.height))
|
||||
|
||||
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_start
|
||||
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
|
||||
@@ -276,60 +337,125 @@ class Scroller(Widget):
|
||||
[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(round(x), round(y)) # round to prevent jumping when settling
|
||||
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, _):
|
||||
for item in self._visible_items:
|
||||
# Skip rendering if not in viewport
|
||||
if not rl.check_collision_recs(item.rect, self._rect):
|
||||
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)
|
||||
|
||||
# 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()
|
||||
# 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)))
|
||||
|
||||
# Draw scroll indicator
|
||||
if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0:
|
||||
_real_content_size = self._content_size - self._rect.height + self._txt_vertical_scroll_indicator.height
|
||||
scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height
|
||||
scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_vertical_scroll_indicator.height)
|
||||
rl.draw_texture_ex(self._txt_vertical_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)
|
||||
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, 166), rl.BLANK)
|
||||
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, 166))
|
||||
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)
|
||||
|
||||
for item in self._items:
|
||||
item.show_event()
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
from enum import IntEnum
|
||||
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 Widget, DialogResult
|
||||
from openpilot.system.ui.widgets.button import Button, ButtonStyle
|
||||
from openpilot.system.ui.widgets.label import Label
|
||||
from openpilot.system.ui.widgets.scroller_tici import Scroller
|
||||
|
||||
SELECTION_COLOR = rl.Color(70, 91, 234, 255) # #465BEA
|
||||
HEADER_BG = rl.Color(51, 51, 51, 255) # #333333
|
||||
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) # #1B1B1B
|
||||
BORDER_COLOR = rl.Color(80, 80, 80, 255)
|
||||
MARGIN = 40
|
||||
OUTER_MARGIN_X = 100
|
||||
OUTER_MARGIN_Y = 80
|
||||
BUTTON_HEIGHT = 90
|
||||
|
||||
class SortMode(IntEnum):
|
||||
ALPHABETICAL = 0
|
||||
DATE_NEWEST = 1
|
||||
DATE_OLDEST = 2
|
||||
FAVORITES = 3
|
||||
|
||||
class SelectionHeader(Widget):
|
||||
def __init__(self, text: str, is_expanded: bool, callback: Callable[[str], None]):
|
||||
super().__init__()
|
||||
self._text = text
|
||||
self._is_expanded = is_expanded
|
||||
self._callback = callback
|
||||
self._font = gui_app.font(FontWeight.BOLD)
|
||||
self._font_size = 40
|
||||
self._pressed = False
|
||||
self.set_rect(rl.Rectangle(0, 0, 0, 70))
|
||||
|
||||
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, rect: rl.Rectangle):
|
||||
# Header background - Match Qt .series-header {#333333}
|
||||
bg_color = rl.Color(64, 64, 64, 255) if self._pressed else HEADER_BG
|
||||
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
|
||||
|
||||
# Arrow - Match Qt text-based arrows
|
||||
arrow = "▼" if self._is_expanded else "▶"
|
||||
arrow_pos = rl.Vector2(rect.x + 30, rect.y + (rect.height - self._font_size) / 2)
|
||||
rl.draw_text_ex(self._font, arrow, arrow_pos, self._font_size, 0, rl.WHITE)
|
||||
|
||||
# Text - Match Qt padding-left: 80px
|
||||
text_pos = rl.Vector2(rect.x + 80, rect.y + (rect.height - self._font_size) / 2)
|
||||
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos):
|
||||
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
|
||||
self._pressed = True
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
|
||||
if self._callback:
|
||||
self._callback(self._text)
|
||||
self._pressed = False
|
||||
|
||||
class SelectionItem(Widget):
|
||||
def __init__(self, text: str, is_selected: bool, callback: Callable[[str], None]):
|
||||
super().__init__()
|
||||
self._text = text
|
||||
self._is_selected = is_selected
|
||||
self._callback = callback
|
||||
self._font = gui_app.font(FontWeight.MEDIUM)
|
||||
self._font_size = 48
|
||||
self._pressed = False
|
||||
self.set_rect(rl.Rectangle(0, 0, 0, 110))
|
||||
|
||||
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, rect: rl.Rectangle):
|
||||
# Background for item - Match Qt .model-option:checked {#465BEA}
|
||||
if self._is_selected:
|
||||
bg_color = rl.Color(70, 91, 234, 255) # #465BEA
|
||||
else:
|
||||
bg_color = rl.Color(90, 90, 90, 255) if self._pressed else rl.Color(79, 79, 79, 255) # #4F4F4F
|
||||
|
||||
rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color)
|
||||
|
||||
# Selection Border - Match Qt {3px WHITE}
|
||||
if self._is_selected:
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, 0.1, 10, 3, rl.WHITE)
|
||||
|
||||
# Text
|
||||
text_size = rl.measure_text_ex(self._font, self._text, self._font_size, 0)
|
||||
text_pos = rl.Vector2(rect.x + 40, rect.y + (rect.height - text_size.y) / 2)
|
||||
rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE)
|
||||
|
||||
# Indicator (Dot for selection instead of radio)
|
||||
if self._is_selected:
|
||||
circle_center = rl.Vector2(rect.x + rect.width - 50, rect.y + rect.height / 2)
|
||||
rl.draw_circle_v(circle_center, 12, rl.WHITE)
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos):
|
||||
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
|
||||
self._pressed = True
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos):
|
||||
if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect):
|
||||
if self._callback:
|
||||
self._callback(self._text)
|
||||
self._pressed = False
|
||||
|
||||
class SelectionDialog(Widget):
|
||||
def __init__(self, title: str, options, current_selection: str = "",
|
||||
on_close: Callable[[DialogResult, str], None] | None = None,
|
||||
model_released_dates: dict[str, str] | None = None,
|
||||
model_file_to_name: dict[str, str] | None = None,
|
||||
user_favorites: list[str] | None = None,
|
||||
community_favorites: list[str] | None = None,
|
||||
on_favorite_toggled: Callable[[str], None] | None = None,
|
||||
favorites_editable: bool = True):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._options_raw = options
|
||||
self._selected_value = current_selection
|
||||
self._on_close = on_close
|
||||
self._model_released_dates = model_released_dates or {}
|
||||
self._name_to_file = {v: k for k, v in (model_file_to_name or {}).items()}
|
||||
self._user_favorites = user_favorites or []
|
||||
self._community_favorites = community_favorites or []
|
||||
self._on_favorite_toggled = on_favorite_toggled
|
||||
self._favorites_editable = favorites_editable
|
||||
|
||||
self._sort_mode = SortMode.ALPHABETICAL
|
||||
self._expanded_series = {s: True for s in (options.keys() if isinstance(options, dict) else [])}
|
||||
|
||||
self._title_label = Label(title, 60, FontWeight.BOLD, text_color=rl.WHITE)
|
||||
self._sort_button = Button("Alphabetical", self._toggle_sort, button_style=ButtonStyle.NORMAL)
|
||||
self._cancel_button = Button("Cancel", self._cancel_button_callback)
|
||||
self._confirm_button = Button("Select", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY)
|
||||
|
||||
self._scroller = None
|
||||
self._build_scroller()
|
||||
|
||||
def _toggle_sort(self):
|
||||
self._sort_mode = SortMode((int(self._sort_mode) + 1) % 4)
|
||||
modes = ["Alphabetical", "Date (Newest)", "Date (Oldest)", "Favorites First"]
|
||||
self._sort_button.set_text(modes[int(self._sort_mode)])
|
||||
self._build_scroller()
|
||||
|
||||
def _toggle_series(self, series: str):
|
||||
self._expanded_series[series] = not self._expanded_series.get(series, True)
|
||||
self._build_scroller()
|
||||
|
||||
def _build_scroller(self):
|
||||
items = []
|
||||
|
||||
if isinstance(self._options_raw, dict):
|
||||
series_keys = list(self._options_raw.keys())
|
||||
priority_series = ["StarPilot", "Comma", "Experimental"]
|
||||
sorted_series_keys = []
|
||||
for p in priority_series:
|
||||
if p in series_keys:
|
||||
sorted_series_keys.append(p)
|
||||
series_keys.remove(p)
|
||||
sorted_series_keys.extend(sorted(series_keys))
|
||||
|
||||
for series in sorted_series_keys:
|
||||
models = self._options_raw[series]
|
||||
if not models:
|
||||
continue
|
||||
|
||||
items.append(SelectionHeader(series, self._expanded_series.get(series, True), self._toggle_series))
|
||||
|
||||
if self._expanded_series.get(series, True):
|
||||
sorted_models = list(models)
|
||||
if self._sort_mode == SortMode.ALPHABETICAL:
|
||||
sorted_models.sort()
|
||||
elif self._sort_mode == SortMode.DATE_NEWEST:
|
||||
def get_date(m):
|
||||
key = self._name_to_file.get(m, m)
|
||||
return self._model_released_dates.get(key, "0000-00-00")
|
||||
sorted_models.sort(key=get_date, reverse=True)
|
||||
elif self._sort_mode == SortMode.DATE_OLDEST:
|
||||
def get_date(m):
|
||||
key = self._name_to_file.get(m, m)
|
||||
return self._model_released_dates.get(key, "9999-99-99")
|
||||
sorted_models.sort(key=get_date)
|
||||
elif self._sort_mode == SortMode.FAVORITES:
|
||||
def is_fav(m):
|
||||
key = self._name_to_file.get(m, m)
|
||||
return key in self._user_favorites or key in self._community_favorites
|
||||
sorted_models.sort(key=is_fav, reverse=True)
|
||||
|
||||
for model in sorted_models:
|
||||
key = self._name_to_file.get(model, model)
|
||||
is_selected = (model == self._selected_value or key == self._selected_value)
|
||||
items.append(SelectionItem(
|
||||
text=model,
|
||||
is_selected=is_selected,
|
||||
callback=self._on_item_selected
|
||||
))
|
||||
else:
|
||||
for option in self._options_raw:
|
||||
items.append(SelectionItem(
|
||||
text=option,
|
||||
is_selected=(option == self._selected_value),
|
||||
callback=self._on_item_selected
|
||||
))
|
||||
|
||||
self._scroller = Scroller(items, line_separator=False, spacing=10)
|
||||
self._scroller.show_event()
|
||||
|
||||
def _toggle_favorite(self, model_name: str):
|
||||
if not self._favorites_editable:
|
||||
return
|
||||
|
||||
key = self._name_to_file.get(model_name, model_name)
|
||||
if self._on_favorite_toggled:
|
||||
self._on_favorite_toggled(key)
|
||||
# Update local state for instant feedback
|
||||
if key in self._user_favorites:
|
||||
self._user_favorites.remove(key)
|
||||
else:
|
||||
self._user_favorites.append(key)
|
||||
self._build_scroller()
|
||||
|
||||
def _on_item_selected(self, val):
|
||||
self._selected_value = val
|
||||
# Instant visual update
|
||||
if self._scroller:
|
||||
for item in self._scroller._items:
|
||||
if isinstance(item, SelectionItem):
|
||||
item._is_selected = (item._text == val)
|
||||
|
||||
def _cancel_button_callback(self):
|
||||
gui_app.set_modal_overlay(None)
|
||||
if self._on_close:
|
||||
self._on_close(DialogResult.CANCEL, "")
|
||||
|
||||
def _confirm_button_callback(self):
|
||||
gui_app.set_modal_overlay(None)
|
||||
if self._on_close:
|
||||
self._on_close(DialogResult.CONFIRM, self._selected_value)
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
if self._scroller:
|
||||
self._scroller.show_event()
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
# Dim background
|
||||
rl.draw_rectangle(0, 0, int(rl.get_screen_width()), int(rl.get_screen_height()), rl.Color(0, 0, 0, 180))
|
||||
|
||||
# Dialog Box
|
||||
dialog_rect = rl.Rectangle(
|
||||
rect.x + OUTER_MARGIN_X,
|
||||
rect.y + OUTER_MARGIN_Y,
|
||||
rect.width - 2 * OUTER_MARGIN_X,
|
||||
rect.height - 2 * OUTER_MARGIN_Y,
|
||||
)
|
||||
rl.draw_rectangle_rounded(dialog_rect, 0.04, 12, BACKGROUND_COLOR)
|
||||
rl.draw_rectangle_rounded_lines_ex(dialog_rect, 0.04, 12, 2, BORDER_COLOR)
|
||||
|
||||
# Title
|
||||
title_width = dialog_rect.width - 2 * MARGIN - 260
|
||||
self._title_label.render(rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, title_width, 80))
|
||||
|
||||
# Sort Button
|
||||
self._sort_button.render(rl.Rectangle(dialog_rect.x + dialog_rect.width - MARGIN - 240, dialog_rect.y + MARGIN, 240, 80))
|
||||
|
||||
# Bottom Buttons
|
||||
btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN
|
||||
btn_width = (dialog_rect.width - 3 * MARGIN) / 2
|
||||
|
||||
self._cancel_button.render(rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT))
|
||||
self._confirm_button.render(rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT))
|
||||
|
||||
# Scrollable Options List
|
||||
scroller_y = dialog_rect.y + MARGIN + 80 + 20
|
||||
scroller_rect = rl.Rectangle(
|
||||
dialog_rect.x + MARGIN,
|
||||
scroller_y,
|
||||
dialog_rect.width - 2 * MARGIN,
|
||||
btn_y - scroller_y - 20
|
||||
)
|
||||
self._scroller.render(scroller_rect)
|
||||
|
||||
return DialogResult.NO_ACTION
|
||||
+30
-36
@@ -1,3 +1,4 @@
|
||||
import abc
|
||||
from collections.abc import Callable
|
||||
|
||||
import pyray as rl
|
||||
@@ -5,22 +6,24 @@ 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 BounceFilter, FirstOrderFilter
|
||||
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
|
||||
|
||||
|
||||
class SmallSlider(Widget):
|
||||
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):
|
||||
# TODO: unify this with BigConfirmationDialogV2
|
||||
super().__init__()
|
||||
self._confirm_callback = confirm_callback
|
||||
self._shimmer_offset = shimmer_offset
|
||||
|
||||
self._font = gui_app.font(FontWeight.DISPLAY)
|
||||
|
||||
self._load_assets()
|
||||
|
||||
self._drag_threshold = -self._rect.width // 2
|
||||
@@ -37,17 +40,13 @@ class SmallSlider(Widget):
|
||||
|
||||
self._is_dragging_circle = False
|
||||
|
||||
self._label = 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)
|
||||
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):
|
||||
self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100))
|
||||
|
||||
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100)
|
||||
self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100)
|
||||
self._circle_bg_pressed_txt = self._circle_bg_txt
|
||||
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
|
||||
...
|
||||
|
||||
@property
|
||||
def confirmed(self) -> bool:
|
||||
@@ -57,15 +56,13 @@ class SmallSlider(Widget):
|
||||
super().show_event()
|
||||
self.reset()
|
||||
|
||||
def reset(self, reset_shimmer: bool = True):
|
||||
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._circle_press_time = None
|
||||
self._circle_scale_filter.x = 1.0
|
||||
if reset_shimmer:
|
||||
self._label.reset_shimmer(self._shimmer_offset)
|
||||
self._label.reset_shimmer(self._shimmer_offset)
|
||||
|
||||
def set_opacity(self, opacity: float, smooth: bool = False):
|
||||
if smooth:
|
||||
@@ -114,15 +111,15 @@ class SmallSlider(Widget):
|
||||
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_time > 0:
|
||||
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._on_confirm()
|
||||
self._confirm_callback_called = True
|
||||
self._on_confirm()
|
||||
|
||||
elif not self._is_dragging_circle:
|
||||
# reset back to right
|
||||
@@ -132,8 +129,6 @@ class SmallSlider(Widget):
|
||||
self._scroll_x_circle_filter.x = self._scroll_x_circle
|
||||
|
||||
def _render(self, _):
|
||||
# TODO: iOS text shimmering animation
|
||||
|
||||
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
|
||||
@@ -154,21 +149,20 @@ class SmallSlider(Widget):
|
||||
)
|
||||
self._label.render(label_rect)
|
||||
|
||||
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 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 = scaled_btn_x + (self._circle_bg_txt.width * scale - self._circle_arrow_txt.width) / 2
|
||||
arrow_y = scaled_btn_y + (self._circle_bg_txt.height * scale - self._circle_arrow_txt.height) / 2
|
||||
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(SmallSlider):
|
||||
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)
|
||||
@@ -179,24 +173,24 @@ class LargerSlider(SmallSlider):
|
||||
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 = self._circle_bg_txt
|
||||
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(SmallSlider):
|
||||
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 = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.WHITE,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
line_height=0.875, shimmer=True)
|
||||
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_hover.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
|
||||
|
||||
|
||||
@@ -206,5 +200,5 @@ class RedBigSlider(BigSlider):
|
||||
|
||||
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_hover.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
|
||||
|
||||
Reference in New Issue
Block a user