This commit is contained in:
firestar5683
2026-04-11 21:56:46 -05:00
parent d2e5f06395
commit d43b7d0d3f
187 changed files with 5499 additions and 6222 deletions
+65 -238
View File
@@ -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()
+4 -82
View File
@@ -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)
+11 -18
View File
@@ -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:
+1 -1
View File
@@ -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
+16
View File
@@ -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)
-43
View File
@@ -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
+26 -56
View File
@@ -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
View File
@@ -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
+59
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+35 -21
View File
@@ -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, _):
+229
View File
@@ -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()
+50 -43
View File
@@ -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()
+7 -6
View File
@@ -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
View File
@@ -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
-290
View File
@@ -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
View File
@@ -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