comma four (#36639)

* squash squash squash

* scroller tici
This commit is contained in:
Shane Smiskol
2025-11-18 22:27:45 -08:00
committed by GitHub
parent b73be441b3
commit 3aaf249236
239 changed files with 10876 additions and 839 deletions
+1
View File
@@ -3,6 +3,7 @@
The user interfaces here are built with [raylib](https://www.raylib.com/).
Quick start:
* set `BIG=1` to run the comma 3X UI (comma four UI runs by default)
* set `SHOW_FPS=1` to show the FPS
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
* set `SCALE=1.5` to scale the entire UI by 1.5x
+25 -7
View File
@@ -27,6 +27,7 @@ MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
MAX_TOUCH_SLOTS = 2
TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out
BIG_UI = os.getenv("BIG", "0") == "1"
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == "1"
SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1"
@@ -78,7 +79,7 @@ DEFAULT_TEXT_COLOR = rl.WHITE
# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles
# The real scales for the fonts below range from 1.212 to 1.266
FONT_SCALE = 1.242
FONT_SCALE = 1.242 if BIG_UI else 1.16
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
@@ -86,12 +87,17 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
LIGHT = "Inter-Light.fnt"
NORMAL = "Inter-Regular.fnt"
NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
BOLD = "Inter-Bold.fnt"
SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "unifont.fnt"
# Small UI fonts
DISPLAY_REGULAR = "Inter-Regular.fnt"
ROMAN = "Inter-Regular.fnt"
DISPLAY = "Inter-Bold.fnt"
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
@@ -181,10 +187,10 @@ class MouseState:
class GuiApplication:
def __init__(self, width: int, height: int):
def __init__(self, width: int | None = None, height: int | None = None):
self._fonts: dict[FontWeight, rl.Font] = {}
self._width = width
self._height = height
self._width = width if width is not None else GuiApplication._default_width()
self._height = height if height is not None else GuiApplication._default_height()
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
@@ -654,5 +660,17 @@ class GuiApplication:
# Apply 0.95 factor for window decorations/taskbar margin
return max(0.3, min(w / self._width, h / self._height) * 0.95)
@staticmethod
def _default_width() -> int:
return 2160 if GuiApplication.big_ui() else 536
gui_app = GuiApplication(2160, 1080)
@staticmethod
def _default_height() -> int:
return 1080 if GuiApplication.big_ui() else 240
@staticmethod
def big_ui() -> bool:
return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI
gui_app = GuiApplication()
+219
View File
@@ -0,0 +1,219 @@
import os
import math
import pyray as rl
from collections.abc import Callable
from enum import Enum
from typing import cast
from openpilot.system.ui.lib.application import gui_app, MouseEvent
from openpilot.system.hardware import TICI
from collections import deque
MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
MIN_DRAG_PIXELS = 12
AUTO_SCROLL_TC_SNAP = 0.025
AUTO_SCROLL_TC = 0.18
BOUNCE_RETURN_RATE = 10.0
REJECT_DECELERATION_FACTOR = 3
MAX_SPEED = 10000.0 # px/s
DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
class ScrollState(Enum):
STEADY = 0
PRESSED = 1
MANUAL_SCROLL = 2
AUTO_SCROLL = 3
class GuiScrollPanel2:
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
self._horizontal = horizontal
self._handle_out_of_bounds = handle_out_of_bounds
self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC
self._state = ScrollState.STEADY
self._offset: rl.Vector2 = rl.Vector2(0, 0)
self._initial_click_event: MouseEvent | None = None
self._previous_mouse_event: MouseEvent | None = None
self._velocity = 0.0 # pixels per second
self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6)
self._enabled: bool | Callable[[], bool] = True
def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._enabled = enabled
@property
def enabled(self) -> bool:
return self._enabled() if callable(self._enabled) else self._enabled
def update(self, bounds: rl.Rectangle, content_size: float) -> float:
if DEBUG:
print('Old state:', self._state)
bounds_size = bounds.width if self._horizontal else bounds.height
for mouse_event in gui_app.mouse_events:
self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
self._previous_mouse_event = mouse_event
self._update_state(bounds_size, content_size)
if DEBUG:
print('Velocity:', self._velocity)
print('Offset X:', self._offset.x, 'Y:', self._offset.y)
print('New state:', self._state)
print()
return self.get_offset()
def _update_state(self, bounds_size: float, content_size: float) -> None:
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
if self._state == ScrollState.AUTO_SCROLL:
# simple exponential return if out of bounds
out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size)
if out_of_bounds and self._handle_out_of_bounds:
if self.get_offset() < (bounds_size - content_size): # too far right
target = bounds_size - content_size
else: # too far left
target = 0.0
dt = rl.get_frame_time() or 1e-6
factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt)
dist = target - self.get_offset()
self.set_offset(self.get_offset() + dist * factor) # ease toward the edge
self._velocity *= (1.0 - factor) # damp any leftover fling
# Steady once we are close enough to the target
if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY:
self.set_offset(target)
self._state = ScrollState.STEADY
elif abs(self._velocity) < MIN_VELOCITY:
self._velocity = 0.0
self._state = ScrollState.STEADY
# Update the offset based on the current velocity
dt = rl.get_frame_time()
self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity
alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt))
self._velocity *= alpha
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size)
if DEBUG:
print('Mouse event:', mouse_event)
mouse_pos = self._get_mouse_pos(mouse_event)
if not self.enabled:
# Reset state if not enabled
self._state = ScrollState.STEADY
self._velocity = 0.0
self._velocity_buffer.clear()
elif self._state == ScrollState.STEADY:
if rl.check_collision_point_rec(mouse_event.pos, bounds):
if mouse_event.left_pressed:
self._state = ScrollState.PRESSED
self._initial_click_event = mouse_event
elif self._state == ScrollState.PRESSED:
initial_click_pos = self._get_mouse_pos(cast(MouseEvent, self._initial_click_event))
diff = abs(mouse_pos - initial_click_pos)
if mouse_event.left_released:
# Special handling for down and up clicks across two frames
# TODO: not sure what that means or if it's accurate anymore
if out_of_bounds:
self._state = ScrollState.AUTO_SCROLL
elif diff <= MIN_DRAG_PIXELS:
self._state = ScrollState.STEADY
else:
self._state = ScrollState.MANUAL_SCROLL
elif diff > MIN_DRAG_PIXELS:
self._state = ScrollState.MANUAL_SCROLL
elif self._state == ScrollState.MANUAL_SCROLL:
if mouse_event.left_released:
# Touch rejection: when releasing finger after swiping and stopping, panel
# reports a few erroneous touch events with high velocity, try to ignore.
# If velocity decelerates very quickly, assume user doesn't intend to auto scroll
high_decel = False
if len(self._velocity_buffer) > 2:
# We limit max to first half since final few velocities can surpass first few
abs_velocity_buffer = [(abs(v), i) for i, v in enumerate(self._velocity_buffer)]
max_idx = max(abs_velocity_buffer[:len(abs_velocity_buffer) // 2])[1]
min_idx = min(abs_velocity_buffer)[1]
if DEBUG:
print('min_idx:', min_idx, 'max_idx:', max_idx, 'velocity buffer:', self._velocity_buffer)
if (abs(self._velocity_buffer[min_idx]) * REJECT_DECELERATION_FACTOR < abs(self._velocity_buffer[max_idx]) and
max_idx < min_idx):
if DEBUG:
print('deceleration too high, going to STEADY')
high_decel = True
# If final velocity is below some threshold, switch to steady state too
low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin
if out_of_bounds or not (high_decel or low_speed):
self._state = ScrollState.AUTO_SCROLL
else:
# TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares
self._velocity = 0.0
self._state = ScrollState.STEADY
self._velocity_buffer.clear()
else:
# Update velocity for when we release the mouse button.
# Do not update velocity on the same frame the mouse was released
previous_mouse_pos = self._get_mouse_pos(cast(MouseEvent, self._previous_mouse_event))
delta_x = mouse_pos - previous_mouse_pos
self._velocity = delta_x / (mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t)
self._velocity = max(-MAX_SPEED, min(MAX_SPEED, self._velocity))
self._velocity_buffer.append(self._velocity)
# rubber-banding: reduce dragging when out of bounds
# TODO: this drifts when dragging quickly
if out_of_bounds:
delta_x *= 0.25
# Update the offset based on the mouse movement
# Use internal _offset directly to preserve precision (don't round via get_offset())
# TODO: make get_offset return float
current_offset = self._offset.x if self._horizontal else self._offset.y
self.set_offset(current_offset + delta_x)
elif self._state == ScrollState.AUTO_SCROLL:
if mouse_event.left_pressed:
# Decide whether to click or scroll (block click if moving too fast)
if abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING:
# Traveling slow enough, click
self._state = ScrollState.PRESSED
self._initial_click_event = mouse_event
else:
# Go straight into manual scrolling to block erroneous input
self._state = ScrollState.MANUAL_SCROLL
# Reset velocity for touch down and up events that happen in back-to-back frames
self._velocity = 0.0
def _get_mouse_pos(self, mouse_event: MouseEvent) -> float:
return mouse_event.pos.x if self._horizontal else mouse_event.pos.y
def get_offset(self) -> int:
return round(self._offset.x if self._horizontal else self._offset.y)
def set_offset(self, value: float) -> None:
if self._horizontal:
self._offset.x = value
else:
self._offset.y = value
@property
def state(self) -> ScrollState:
return self._state
def is_touch_valid(self) -> bool:
# MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state
return bool(self._state != ScrollState.MANUAL_SCROLL)
+3 -1
View File
@@ -191,7 +191,9 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
assert len(pts) % 2 == 0, "Interleaving expects even number of points"
# assert len(pts) % 2 == 0, "Interleaving expects even number of points"
if len(pts) % 2 != 0:
pts = pts[:-1]
tri_strip = []
for i in range(len(pts) // 2):
+2 -1
View File
@@ -5,9 +5,10 @@ from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
+3 -2
View File
@@ -35,7 +35,7 @@ except Exception:
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
SCAN_PERIOD_SECONDS = 10
SCAN_PERIOD_SECONDS = 5
class SecurityType(IntEnum):
@@ -75,6 +75,7 @@ class Network:
is_connected: bool
security_type: SecurityType
is_saved: bool
ip_address: str = "" # TODO: implement
@classmethod
def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network":
@@ -627,7 +628,7 @@ class WifiManager:
known_connections = self._get_connections()
networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()]
networks.sort(key=lambda n: (-n.is_connected, -n.strength, n.ssid.lower()))
networks.sort(key=lambda n: (-n.is_connected, n.ssid.lower()))
self._networks = networks
self._update_ipv4_address()
+9 -8
View File
@@ -3,7 +3,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
if not word:
return []
@@ -11,7 +11,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
remaining = word
while remaining:
if measure_text_cached(font, remaining, font_size).x <= max_width:
if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
@@ -22,7 +22,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
width = measure_text_cached(font, substring, font_size).x
width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
@@ -40,9 +40,10 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
_cache: dict[int, list[str]] = {}
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
font = font_fallback(font)
key = hash((font.texture.id, text, font_size, max_width))
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
@@ -69,7 +70,7 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
current_line: list[str] = []
for word in words:
word_width = int(measure_text_cached(font, word, font_size).x)
word_width = measure_text_cached(font, word, font_size, spacing).x
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
@@ -79,12 +80,12 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
current_line = []
# Break the long word into parts
lines.extend(_break_long_word(font, word, font_size, max_width))
lines.extend(_break_long_word(font, word, font_size, max_width, spacing))
continue
# Measure the actual joined string to get accurate width (accounts for kerning, etc.)
test_line = " ".join(current_line + [word]) if current_line else word
test_width = int(measure_text_cached(font, test_line, font_size).x)
test_width = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.slider import SmallSlider
from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
NONE = 0
RESETTING = 1
FAILED = 2
class Reset(Widget):
def __init__(self, mode):
super().__init__()
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._cancel_button = SmallButton("cancel")
self._cancel_button.set_click_callback(self._cancel_callback)
self._reboot_button = FullRoundedButton("reboot")
self._reboot_button.set_click_callback(self._do_reboot)
self._confirm_slider = SmallSlider("reset", self._confirm)
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_reboot(self):
if PC:
return
os.system("sudo reboot")
def _do_erase(self):
if PC:
return
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
self._timeout_st = time.monotonic()
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50)
gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80)
gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9)
if self._reset_state != ResetState.RESETTING:
# fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel
self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage)
self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8)
if self._mode == ResetMode.RECOVER:
self._cancel_button.set_text("reboot")
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED:
self._cancel_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._cancel_button.rect.height,
self._cancel_button.rect.width,
self._cancel_button.rect.height))
if self._reset_state != ResetState.FAILED:
self._confirm_slider.render(rl.Rectangle(
rect.x + rect.width - self._confirm_slider.rect.width,
rect.y + rect.height - self._confirm_slider.rect.height,
self._confirm_slider.rect.width,
self._confirm_slider.rect.height))
else:
self._reboot_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._reboot_button.rect.height,
self._reboot_button.rect.width,
self._reboot_button.rect.height))
return self._render_status
def _confirm(self):
self.start_reset()
def _get_body_text(self):
if self._reset_state == ResetState.RESETTING:
return "Resetting device... This may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. Partition may be corrupted."
return "All content and settings will be erased."
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset")
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)):
break
if __name__ == "__main__":
main()
+727
View File
@@ -0,0 +1,727 @@
#!/usr/bin/env python3
from abc import abstractmethod
import os
import re
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
from collections.abc import Callable
import pyray as rl
from cereal import log
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton,
SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton,
FullRoundedButton)
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.slider import LargerSlider
from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici
from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
NetworkType = log.DeviceState.NetworkType
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class NetworkConnectivityMonitor:
def __init__(self, should_check: Callable[[], bool] | None = None, check_interval: float = 0.5):
self.network_connected = threading.Event()
self.wifi_connected = threading.Event()
self._should_check = should_check or (lambda: True)
self._check_interval = check_interval
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
def start(self):
self._stop_event.clear()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
if self._thread is not None:
self._stop_event.set()
self._thread.join()
self._thread = None
def reset(self):
self.network_connected.clear()
self.wifi_connected.clear()
def _run(self):
while not self._stop_event.is_set():
if self._should_check():
try:
request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
urllib.request.urlopen(request, timeout=0.5)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
except Exception:
self.reset()
else:
self.reset()
if self._stop_event.wait(timeout=self._check_interval):
break
class SetupState(IntEnum):
GETTING_STARTED = 0
NETWORK_SETUP = 1
NETWORK_SETUP_CUSTOM_SOFTWARE = 8
SOFTWARE_SELECTION = 2
CUSTOM_SOFTWARE = 3
DOWNLOADING = 4
DOWNLOAD_FAILED = 5
CUSTOM_SOFTWARE_WARNING = 6
class StartPage(Widget):
def __init__(self):
super().__init__()
self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224)
self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224)
def _render(self, rect: rl.Rectangle):
draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE)
self._title.render(rect)
class SoftwareSelectionPage(Widget):
def __init__(self, use_openpilot_callback: Callable,
use_custom_software_callback: Callable):
super().__init__()
self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback)
self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
def reset(self):
self._openpilot_slider.reset()
self._custom_software_slider.reset()
def _render(self, rect: rl.Rectangle):
self._openpilot_slider.set_opacity(1.0 - self._custom_software_slider.slider_percentage)
self._custom_software_slider.set_opacity(1.0 - self._openpilot_slider.slider_percentage)
openpilot_rect = rl.Rectangle(
rect.x + (rect.width - self._openpilot_slider.rect.width) / 2,
rect.y,
self._openpilot_slider.rect.width,
rect.height / 2,
)
self._openpilot_slider.render(openpilot_rect)
custom_software_rect = rl.Rectangle(
rect.x + (rect.width - self._custom_software_slider.rect.width) / 2,
rect.y + rect.height / 2,
self._custom_software_slider.rect.width,
rect.height / 2,
)
self._custom_software_slider.render(custom_software_rect)
class TermsHeader(Widget):
def __init__(self, text: str, icon_texture: rl.Texture):
super().__init__()
self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
line_height=0.8)
self._icon_texture = icon_texture
self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height))
def set_title(self, text: str):
self._title.set_text(text)
def set_icon(self, icon_texture: rl.Texture):
self._icon_texture = icon_texture
def _render(self, _):
rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y),
0.0, 1.0, rl.WHITE)
# May expand outside parent rect
title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16))
title_rect = rl.Rectangle(
self._rect.x + self._icon_texture.width + 16,
self._rect.y + (self._rect.height - title_content_height) / 2,
self._rect.width - self._icon_texture.width - 16,
title_content_height,
)
self._title.render(title_rect)
class TermsPage(Widget):
ITEM_SPACING = 20
def __init__(self, continue_callback: Callable, back_callback: Callable | None = None,
back_text: str = "back", continue_text: str = "accept"):
super().__init__()
# TODO: use Scroller
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._continue_text = continue_text
self._continue_button: WideRoundedButton | FullRoundedButton
if back_callback is not None:
self._continue_button = WideRoundedButton(continue_text)
else:
self._continue_button = FullRoundedButton(continue_text)
self._continue_button.set_enabled(False)
self._continue_button.set_opacity(0.0)
self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
self._continue_button.set_click_callback(continue_callback)
self._enable_back = back_callback is not None
self._back_button = SmallButton(back_text)
self._back_button.set_opacity(0.0)
self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
self._back_button.set_click_callback(back_callback)
self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78))
self._scroll_down_indicator.set_enabled(False)
def reset(self):
self._scroll_panel.set_offset(0)
self._continue_button.set_enabled(False)
self._continue_button.set_opacity(0.0)
self._back_button.set_enabled(False)
self._back_button.set_opacity(0.0)
self._scroll_down_indicator.set_opacity(1.0)
@property
@abstractmethod
def _content_height(self):
pass
@property
def _scrolled_down_offset(self):
return -self._content_height + (self._continue_button.rect.height + 16 + 30)
@abstractmethod
def _render_content(self, scroll_offset):
pass
def _render(self, _):
scroll_offset = self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16)
if scroll_offset <= self._scrolled_down_offset:
# don't show back if not enabled
if self._enable_back:
self._back_button.set_enabled(True)
self._back_button.set_opacity(1.0, smooth=True)
self._continue_button.set_enabled(True)
self._continue_button.set_opacity(1.0, smooth=True)
self._scroll_down_indicator.set_opacity(0.0, smooth=True)
else:
self._back_button.set_enabled(False)
self._back_button.set_opacity(0.0, smooth=True)
self._continue_button.set_enabled(False)
self._continue_button.set_opacity(0.0, smooth=True)
self._scroll_down_indicator.set_opacity(1.0, smooth=True)
# Render content
self._render_content(scroll_offset)
# black gradient at top and bottom for scrolling content
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y),
int(self._rect.width), 20, rl.BLACK, rl.BLANK)
rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20),
int(self._rect.width), 20, rl.BLANK, rl.BLACK)
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
continue_x = self._rect.x + 8
if self._enable_back:
continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8
self._continue_button.render(rl.Rectangle(
continue_x,
self._rect.y + self._rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
self._scroll_down_indicator.render(rl.Rectangle(
self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8,
self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8,
self._scroll_down_indicator.rect.width,
self._scroll_down_indicator.rect.height,
))
class CustomSoftwareWarningPage(TermsPage):
def __init__(self, continue_callback: Callable, back_callback: Callable):
super().__init__(continue_callback, back_callback)
self._title_header = TermsHeader("use caution installing\n3rd party software",
gui_app.texture("icons_mici/setup/warning.png", 66, 60))
self._body = UnifiedLabel("• It has not been tested by comma.\n" +
"• It may not comply with relevant safety standards.\n" +
"• It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60))
self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai",
36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
@property
def _content_height(self):
return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset()
def _render_content(self, scroll_offset):
self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset)
self._title_header.render()
body_rect = rl.Rectangle(
self._rect.x + 8,
self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
self._rect.width - 50,
self._body.get_content_height(int(self._rect.width - 50)),
)
self._body.render(body_rect)
self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING)
self._restore_header.render()
self._restore_body.render(rl.Rectangle(
self._rect.x + 8,
self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING,
self._rect.width - 50,
self._restore_body.get_content_height(int(self._rect.width - 50)),
))
class DownloadingPage(Widget):
def __init__(self):
super().__init__()
self._title_label = UnifiedLabel("downloading", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY)
self._progress_label = UnifiedLabel("", 128, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)),
font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._progress = 0
def set_progress(self, progress: int):
self._progress = progress
self._progress_label.set_text(f"{progress}%")
def _render(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 20,
rect.y + 10,
rect.width,
64,
))
self._progress_label.render(rl.Rectangle(
rect.x + 20,
rect.y + 20,
rect.width,
rect.height,
))
class FailedPage(Widget):
def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"):
super().__init__()
self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.DISPLAY)
self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
font_weight=FontWeight.ROMAN)
self._reboot_button = SmallRedPillButton("reboot")
self._reboot_button.set_click_callback(reboot_callback)
self._retry_button = WideRoundedButton("retry")
self._retry_button.set_click_callback(retry_callback)
def set_reason(self, reason: str):
self._reason_label.set_text(reason)
def _render(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 10,
rect.width,
64,
))
self._reason_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 10 + 64,
rect.width,
36,
))
self._reboot_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._reboot_button.rect.height,
self._reboot_button.rect.width,
self._reboot_button.rect.height,
))
self._retry_button.render(rl.Rectangle(
rect.x + 8 + self._reboot_button.rect.width + 8,
rect.y + rect.height - self._retry_button.rect.height,
self._retry_button.rect.width,
self._retry_button.rect.height,
))
class NetworkSetupState(IntEnum):
MAIN = 0
WIFI_PANEL = 1
class NetworkSetupPage(Widget):
def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable):
super().__init__()
self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN))
self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50)
self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50)
self._waiting_text = "waiting for internet..."
self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt)
back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32)
self._back_button = SmallCircleIconButton(back_txt)
self._back_button.set_click_callback(back_callback)
self._wifi_button = SmallerRoundedButton("wifi")
self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL))
self._continue_button = WidishRoundedButton("continue")
self._continue_button.set_enabled(False)
self._continue_button.set_click_callback(continue_callback)
self._state = NetworkSetupState.MAIN
def set_state(self, state: NetworkSetupState):
self._state = state
def set_has_internet(self, has_internet: bool):
if has_internet:
self._network_header.set_title("connected to internet")
self._network_header.set_icon(self._wifi_full_txt)
self._continue_button.set_enabled(True)
else:
self._network_header.set_title(self._waiting_text)
self._network_header.set_icon(self._no_wifi_txt)
self._continue_button.set_enabled(False)
def show_event(self):
super().show_event()
self._state = NetworkSetupState.MAIN
self._wifi_ui.show_event()
def hide_event(self):
super().hide_event()
self._wifi_ui.hide_event()
def _render(self, _):
if self._state == NetworkSetupState.MAIN:
self._network_header.render(rl.Rectangle(
self._rect.x + 16,
self._rect.y + 16,
self._rect.width - 32,
self._network_header.rect.height,
))
self._back_button.render(rl.Rectangle(
self._rect.x + 8,
self._rect.y + self._rect.height - self._back_button.rect.height,
self._back_button.rect.width,
self._back_button.rect.height,
))
self._wifi_button.render(rl.Rectangle(
self._rect.x + 8 + self._back_button.rect.width + 10,
self._rect.y + self._rect.height - self._wifi_button.rect.height,
self._wifi_button.rect.width,
self._wifi_button.rect.height,
))
self._continue_button.render(rl.Rectangle(
self._rect.x + self._rect.width - self._continue_button.rect.width - 8,
self._rect.y + self._rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
else:
self._wifi_ui.render(self._rect)
class Setup(Widget):
def __init__(self):
super().__init__()
self.state = SetupState.GETTING_STARTED
self.failed_url = ""
self.failed_reason = ""
self.download_url = ""
self.download_progress = 0
self.download_thread = None
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(True)
self._network_monitor = NetworkConnectivityMonitor(
lambda: self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
)
self._start_page = StartPage()
self._start_page.set_click_callback(self._getting_started_button_callback)
self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback,
self._network_setup_back_button_callback)
self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback,
self._software_selection_custom_software_button_callback)
self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback)
self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue,
self._custom_software_warning_back_button_callback)
self._downloading_page = DownloadingPage()
def _update_state(self):
self._wifi_manager.process_callbacks()
def _set_state(self, state: SetupState):
self.state = state
if self.state == SetupState.SOFTWARE_SELECTION:
self._software_selection_page.reset()
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self._custom_software_warning_page.reset()
if self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
self._network_setup_page.show_event()
self._network_monitor.reset()
self._network_monitor.start()
else:
self._network_setup_page.hide_event()
self._network_monitor.stop()
def _render(self, rect: rl.Rectangle):
if self.state == SetupState.GETTING_STARTED:
self._start_page.render(rect)
elif self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
self.render_network_setup(rect)
elif self.state == SetupState.SOFTWARE_SELECTION:
self._software_selection_page.render(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self._custom_software_warning_page.render(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE:
self.render_custom_software()
elif self.state == SetupState.DOWNLOADING:
self.render_downloading(rect)
elif self.state == SetupState.DOWNLOAD_FAILED:
self._download_failed_page.render(rect)
def _custom_software_warning_back_button_callback(self):
self._set_state(SetupState.SOFTWARE_SELECTION)
def _custom_software_warning_continue_button_callback(self):
self._set_state(SetupState.CUSTOM_SOFTWARE)
def _getting_started_button_callback(self):
self._set_state(SetupState.SOFTWARE_SELECTION)
def _software_selection_back_button_callback(self):
self._set_state(SetupState.GETTING_STARTED)
def _software_selection_continue_button_callback(self):
self.use_openpilot()
def _software_selection_custom_software_button_callback(self):
self._set_state(SetupState.CUSTOM_SOFTWARE_WARNING)
def _software_selection_custom_software_continue(self):
self._set_state(SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
def _download_failed_startover_button_callback(self):
self._set_state(SetupState.GETTING_STARTED)
def _network_setup_back_button_callback(self):
self._set_state(SetupState.SOFTWARE_SELECTION)
def _network_setup_continue_button_callback(self):
self._network_monitor.stop()
if self.state == SetupState.NETWORK_SETUP:
self.download(OPENPILOT_URL)
elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
self._set_state(SetupState.CUSTOM_SOFTWARE)
def close(self):
self._network_monitor.stop()
def render_network_setup(self, rect: rl.Rectangle):
self._network_setup_page.render(rect)
self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
def render_downloading(self, rect: rl.Rectangle):
self._downloading_page.set_progress(self.download_progress)
self._downloading_page.render(rect)
def render_custom_software(self):
def handle_keyboard_result(text):
url = text.strip()
if url:
self.download(url)
def handle_keyboard_exit(result):
if result == DialogResult.CANCEL:
self._set_state(SetupState.SOFTWARE_SELECTION)
keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result)
gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self._set_state(SetupState.NETWORK_SETUP)
def download(self, url: str):
# autocomplete incomplete URLs
if re.match("^([^/.]+)/([^/]+)$", url):
url = f"https://installer.comma.ai/{url}"
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self._set_state(SetupState.DOWNLOADING)
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _download_thread(self):
try:
import tempfile
fd, tmpfile = tempfile.mkstemp(prefix="installer_")
headers = {"User-Agent": USER_AGENT,
"X-openpilot-serial": HARDWARE.get_serial(),
"X-openpilot-device-type": HARDWARE.get_device_type()}
req = urllib.request.Request(self.download_url, headers=headers)
with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
block_size = 8192
while True:
buffer = response.read(block_size)
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
if total_size:
self.download_progress = int(downloaded * 100 / total_size)
self._downloading_page.set_progress(self.download_progress)
is_elf = False
with open(tmpfile, 'rb') as f:
header = f.read(4)
is_elf = header == b'\x7fELF'
if not is_elf:
self.download_failed(self.download_url, "No custom software found at this URL.")
return
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
except urllib.error.HTTPError as e:
if e.code == 409:
error_msg = "Incompatible openpilot version"
self.download_failed(self.download_url, error_msg)
except Exception:
error_msg = "Invalid URL"
self.download_failed(self.download_url, error_msg)
def download_failed(self, url: str, reason: str):
self.failed_url = url
self.failed_reason = reason
self._download_failed_page.set_reason(reason)
self._set_state(SetupState.DOWNLOAD_FAILED)
def main():
try:
gui_app.init_window("Setup")
setup = Setup()
for should_render in gui_app.render():
if should_render:
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
setup.close()
except Exception as e:
print(f"Setup error: {e}")
finally:
gui_app.close()
if __name__ == "__main__":
main()
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wifi_manager import WifiManager, Network
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel
from openpilot.system.ui.widgets.button import FullRoundedButton
from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor
class Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
FAILED = 3
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
self._current_network_strength = -1
self.progress_value = 0
self.progress_text = "loading"
self.process = None
self.update_thread = None
self._wifi_manager = WifiManager()
self._wifi_manager.set_active(True)
self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback,
self._network_setup_back_callback)
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
self._network_monitor = NetworkConnectivityMonitor()
self._network_monitor.start()
# Buttons
self._continue_button = FullRoundedButton("continue")
self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI))
self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255),
font_weight=FontWeight.DISPLAY)
self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
font_weight=FontWeight.ROMAN)
self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback,
title="update failed")
def _network_setup_back_callback(self):
self.set_current_screen(Screen.PROMPT)
def _network_setup_continue_callback(self):
self.install_update()
def _update_failed_retry_callback(self):
self.set_current_screen(Screen.PROMPT)
def _on_network_updated(self, networks: list[Network]):
self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1)
def set_current_screen(self, screen: Screen):
if self.current_screen != screen:
if screen == Screen.PROGRESS:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.WIFI:
if self._network_setup_page:
self._network_setup_page.show_event()
elif screen == Screen.PROMPT:
if self._network_setup_page:
self._network_setup_page.hide_event()
elif screen == Screen.FAILED:
if self._network_setup_page:
self._network_setup_page.hide_event()
self.current_screen = screen
def install_update(self):
self.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "downloading"
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
for line in self.process.stdout:
parts = line.strip().split(":")
if len(parts) == 2:
self.progress_text = parts[0].lower()
try:
self.progress_value = int(float(parts[1]))
except ValueError:
pass
exit_code = self.process.wait()
if exit_code == 0:
HARDWARE.reboot()
else:
self.set_current_screen(Screen.FAILED)
def render_prompt_screen(self, rect: rl.Rectangle):
self._title_label.render(rl.Rectangle(
rect.x + 8,
rect.y - 5,
rect.width,
48,
))
subtitle_width = rect.width - 16
subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width))
self._subtitle_label.render(rl.Rectangle(
rect.x + 8,
rect.y + 48,
subtitle_width,
subtitle_height,
))
self._continue_button.render(rl.Rectangle(
rect.x + 8,
rect.y + rect.height - self._continue_button.rect.height,
self._continue_button.rect.width,
self._continue_button.rect.height,
))
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8)
if ' ' in self.progress_text:
font_size = 62
else:
font_size = 82
gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
progress_value = f"{self.progress_value}%"
text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y
progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18,
self._rect.width - 12, text_height)
gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN,
color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)))
def _update_state(self):
self._wifi_manager.process_callbacks()
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
self._network_setup_page.render(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
elif self.current_screen == Screen.FAILED:
self._update_failed_page.render(rect)
def close(self):
self._network_monitor.stop()
def main():
if len(sys.argv) < 3:
print("Usage: updater.py <updater_path> <manifest_path>")
sys.exit(1)
updater_path = sys.argv[1]
manifest_path = sys.argv[2]
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
updater.close()
except Exception as e:
print(f"Updater error: {e}")
finally:
gui_app.close()
if __name__ == "__main__":
main()
+7 -128
View File
@@ -1,135 +1,14 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
NONE = 0
CONFIRM = 1
RESETTING = 2
FAILED = 3
class Reset(Widget):
def __init__(self, mode):
super().__init__()
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._cancel_button = Button("Cancel", self._cancel_callback)
self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_erase(self):
if PC:
return
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
self._timeout_st = time.monotonic()
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
gui_text_box(text_rect, self._get_body_text(), 90)
button_height = 160
button_spacing = 50
button_top = rect.y + rect.height - button_height
button_width = (rect.width - button_spacing) / 2.0
if self._reset_state != ResetState.RESETTING:
if self._mode == ResetMode.RECOVER:
self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
elif self._mode == ResetMode.USER_RESET:
self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
if self._reset_state != ResetState.FAILED:
self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
else:
self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
return self._render_status
def _confirm(self):
if self._reset_state == ResetState.CONFIRM:
self.start_reset()
else:
self._reset_state = ResetState.CONFIRM
def _get_body_text(self):
if self._reset_state == ResetState.CONFIRM:
return "Are you sure you want to reset your device?"
if self._reset_state == ResetState.RESETTING:
return "Resetting device...\nThis may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
from openpilot.system.ui.lib.application import gui_app
import openpilot.system.ui.tici_reset as tici_reset
import openpilot.system.ui.mici_reset as mici_reset
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset", 20)
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
break
if gui_app.big_ui():
tici_reset.main()
else:
mici_reset.main()
if __name__ == "__main__":
+7 -443
View File
@@ -1,450 +1,14 @@
#!/usr/bin/env python3
import os
import re
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
import pyray as rl
from cereal import log
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
NetworkType = log.DeviceState.NetworkType
MARGIN = 50
TITLE_FONT_SIZE = 90
TITLE_FONT_WEIGHT = FontWeight.MEDIUM
NEXT_BUTTON_WIDTH = 310
BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class SetupState(IntEnum):
LOW_VOLTAGE = 0
GETTING_STARTED = 1
NETWORK_SETUP = 2
SOFTWARE_SELECTION = 3
CUSTOM_SOFTWARE = 4
DOWNLOADING = 5
DOWNLOAD_FAILED = 6
CUSTOM_SOFTWARE_WARNING = 7
class Setup(Widget):
def __init__(self):
super().__init__()
self.state = SetupState.GETTING_STARTED
self.network_check_thread = None
self.network_connected = threading.Event()
self.wifi_connected = threading.Event()
self.stop_network_check_thread = threading.Event()
self.failed_url = ""
self.failed_reason = ""
self.download_url = ""
self.download_progress = 0
self.download_thread = None
self.wifi_ui = WifiManagerUI(WifiManager())
self.keyboard = Keyboard()
self.selected_radio = None
self.warning = gui_app.texture("icons/warning.png", 150, 150)
self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=rl.Color(255, 89, 79, 255), text_padding=20)
self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._software_selection_continue_button.set_enabled(False)
self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_padding=20)
self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._network_setup_continue_button.set_enabled(False)
self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._custom_software_warning_continue_button.set_enabled(False)
self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=rl.Color(255, 89, 79, 255),
text_padding=60)
self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
+ "⚠️ It has not been tested by comma.\n\n"
+ "⚠️ It may not comply with relevant safety standards.\n\n"
+ "⚠️ It may cause damage to your device and/or vehicle.\n\n"
+ "If you'd like to proceed, use https://flash.comma.ai "
+ "to restore your device to a factory state later.",
68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
try:
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
voltage = float(f.read().strip()) / 1000.0
if voltage < 7:
self.state = SetupState.LOW_VOLTAGE
except (FileNotFoundError, ValueError):
self.state = SetupState.LOW_VOLTAGE
def _render(self, rect: rl.Rectangle):
if self.state == SetupState.LOW_VOLTAGE:
self.render_low_voltage(rect)
elif self.state == SetupState.GETTING_STARTED:
self.render_getting_started(rect)
elif self.state == SetupState.NETWORK_SETUP:
self.render_network_setup(rect)
elif self.state == SetupState.SOFTWARE_SELECTION:
self.render_software_selection(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self.render_custom_software_warning(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE:
self.render_custom_software()
elif self.state == SetupState.DOWNLOADING:
self.render_downloading(rect)
elif self.state == SetupState.DOWNLOAD_FAILED:
self.render_download_failed(rect)
def _low_voltage_continue_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _custom_software_warning_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _custom_software_warning_continue_button_callback(self):
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def _getting_started_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _software_selection_back_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _software_selection_continue_button_callback(self):
if self._software_selection_openpilot_button.selected:
self.use_openpilot()
else:
self.state = SetupState.CUSTOM_SOFTWARE_WARNING
def _download_failed_startover_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _network_setup_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _network_setup_continue_button_callback(self):
self.stop_network_check_thread.set()
if self._software_selection_openpilot_button.selected:
self.download(OPENPILOT_URL)
else:
self.state = SetupState.CUSTOM_SOFTWARE
def render_low_voltage(self, rect: rl.Rectangle):
rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
button_width = (rect.width - MARGIN * 3) / 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
def render_getting_started(self, rect: rl.Rectangle):
self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
BODY_FONT_SIZE * FONT_SCALE * 3))
btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
self._getting_started_button.render(btn_rect)
triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
def check_network_connectivity(self):
while not self.stop_network_check_thread.is_set():
if self.state == SetupState.NETWORK_SETUP:
try:
urllib.request.urlopen(OPENPILOT_URL, timeout=2)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
else:
self.wifi_connected.clear()
except Exception:
self.network_connected.clear()
time.sleep(1)
def start_network_check(self):
if self.network_check_thread is None or not self.network_check_thread.is_alive():
self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
self.network_check_thread.start()
def close(self):
if self.network_check_thread is not None:
self.stop_network_check_thread.set()
self.network_check_thread.join()
def render_network_setup(self, rect: rl.Rectangle):
self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
self.wifi_ui.render(wifi_content_rect)
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
# Check network connectivity status
continue_enabled = self.network_connected.is_set()
self._network_setup_continue_button.set_enabled(continue_enabled)
continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
self._network_setup_continue_button.set_text(continue_text)
self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_software_selection(self, rect: rl.Rectangle):
self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
radio_height = 230
radio_spacing = 30
self._software_selection_continue_button.set_enabled(False)
openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
self._software_selection_openpilot_button.render(openpilot_rect)
if self._software_selection_openpilot_button.selected:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_custom_software_button.selected = False
custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
radio_height)
self._software_selection_custom_software_button.render(custom_rect)
if self._software_selection_custom_software_button.selected:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_openpilot_button.selected = False
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_downloading(self, rect: rl.Rectangle):
self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
TITLE_FONT_SIZE * FONT_SCALE))
def render_download_failed(self, rect: rl.Rectangle):
self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
self._download_failed_url_label.set_text(self.failed_url)
self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
self._download_failed_body_label.set_text(self.failed_reason)
self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_custom_software_warning(self, rect: rl.Rectangle):
warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
button_width = (rect.width - MARGIN * 3) / 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
y_offset = rect.y + offset
self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
rl.end_scissor_mode()
self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
if offset < (rect.height - warn_rect.height):
self._custom_software_warning_continue_button.set_enabled(True)
self._custom_software_warning_continue_button.set_text("Continue")
def render_custom_software(self):
def handle_keyboard_result(result):
# Enter pressed
if result == 1:
url = self.keyboard.text
self.keyboard.clear()
if url:
self.download(url)
# Cancel pressed
elif result == 0:
self.state = SetupState.SOFTWARE_SELECTION
self.keyboard.reset(min_text_size=1)
self.keyboard.set_title("Enter URL", "for Custom Software")
gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def download(self, url: str):
# autocomplete incomplete URLs
if re.match("^([^/.]+)/([^/]+)$", url):
url = f"https://installer.comma.ai/{url}"
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self.state = SetupState.DOWNLOADING
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _download_thread(self):
try:
import tempfile
fd, tmpfile = tempfile.mkstemp(prefix="installer_")
headers = {"User-Agent": USER_AGENT,
"X-openpilot-serial": HARDWARE.get_serial(),
"X-openpilot-device-type": HARDWARE.get_device_type()}
req = urllib.request.Request(self.download_url, headers=headers)
with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
block_size = 8192
while True:
buffer = response.read(block_size)
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
if total_size:
self.download_progress = int(downloaded * 100 / total_size)
is_elf = False
with open(tmpfile, 'rb') as f:
header = f.read(4)
is_elf = header == b'\x7fELF'
if not is_elf:
self.download_failed(self.download_url, "No custom software found at this URL.")
return
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
except urllib.error.HTTPError as e:
if e.code == 409:
error_msg = e.read().decode("utf-8")
self.download_failed(self.download_url, error_msg)
except Exception:
error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
self.download_failed(self.download_url, error_msg)
def download_failed(self, url: str, reason: str):
self.failed_url = url
self.failed_reason = reason
self.state = SetupState.DOWNLOAD_FAILED
from openpilot.system.ui.lib.application import gui_app
import openpilot.system.ui.tici_setup as tici_setup
import openpilot.system.ui.mici_setup as mici_setup
def main():
try:
gui_app.init_window("Setup", 20)
setup = Setup()
for should_render in gui_app.render():
if should_render:
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
setup.close()
except Exception as e:
print(f"Setup error: {e}")
finally:
gui_app.close()
if gui_app.big_ui():
tici_setup.main()
else:
mici_setup.main()
if __name__ == "__main__":
+14 -5
View File
@@ -9,11 +9,20 @@ from openpilot.system.ui.text import wrap_text
from openpilot.system.ui.widgets import Widget
# Constants
PROGRESS_BAR_WIDTH = 1000
PROGRESS_BAR_HEIGHT = 20
if gui_app.big_ui():
PROGRESS_BAR_WIDTH = 1000
PROGRESS_BAR_HEIGHT = 20
TEXTURE_SIZE = 360
WRAPPED_SPACING = 50
CENTERED_SPACING = 150
else:
PROGRESS_BAR_WIDTH = 268
PROGRESS_BAR_HEIGHT = 10
TEXTURE_SIZE = 140
WRAPPED_SPACING = 10
CENTERED_SPACING = 20
DEGREES_PER_SECOND = 360.0 # one full rotation per second
MARGIN_H = 100
TEXTURE_SIZE = 360
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
@@ -43,12 +52,12 @@ class Spinner(Widget):
def _render(self, rect: rl.Rectangle):
if self._wrapped_lines:
# Calculate total height required for spinner and text
spacing = 50
spacing = WRAPPED_SPACING
total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT
center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
else:
# Center spinner vertically
spacing = 150
spacing = CENTERED_SPACING
center_y = rect.height / 2.0
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
+15 -8
View File
@@ -3,17 +3,24 @@ import re
import sys
import pyray as rl
from openpilot.system.hardware import HARDWARE, PC
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.application import BIG_UI, gui_app
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
MARGIN = 50
SPACING = 40
FONT_SIZE = 72
LINE_HEIGHT = 80
BUTTON_SIZE = rl.Vector2(310, 160)
if BIG_UI:
MARGIN = 50
SPACING = 40
FONT_SIZE = 72
LINE_HEIGHT = 80
BUTTON_SIZE = rl.Vector2(310, 160)
else:
MARGIN = 20
SPACING = 30
FONT_SIZE = 25
LINE_HEIGHT = 25
BUTTON_SIZE = rl.Vector2(150, 80)
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
The text is long enough to demonstrate scrolling and word wrapping.""" * 30
@@ -31,7 +38,7 @@ def wrap_text(text, font_size, max_width):
continue
indent = re.match(r"^\s*", paragraph).group()
current_line = indent
words = re.split(r"(\s+)", paragraph[len(indent):])
words = re.split(r"(\s+|-)", paragraph[len(indent):])
while len(words):
word = words.pop(0)
test_line = current_line + word + (words.pop(0) if words else "")
@@ -57,7 +64,7 @@ class TextWindow(Widget):
self._scroll_panel._offset_filter_y.x = -max(self._content_rect.height - self._textarea_rect.height, 0)
button_text = "Exit" if PC else "Reboot"
self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER)
self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER, font_size=FONT_SIZE)
@staticmethod
def _on_button_clicked():
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
from enum import IntEnum
import pyray as rl
from openpilot.system.hardware import PC
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
class ResetMode(IntEnum):
USER_RESET = 0 # user initiated a factory reset from openpilot
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
class ResetState(IntEnum):
NONE = 0
CONFIRM = 1
RESETTING = 2
FAILED = 3
class Reset(Widget):
def __init__(self, mode):
super().__init__()
self._mode = mode
self._previous_reset_state = None
self._reset_state = ResetState.NONE
self._cancel_button = Button("Cancel", self._cancel_callback)
self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
self._render_status = True
def _cancel_callback(self):
self._render_status = False
def _do_erase(self):
if PC:
return
# Removing data and formatting
rm = os.system("sudo rm -rf /data/*")
os.system(f"sudo umount {USERDATA}")
fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
if rm == 0 or fmt == 0:
os.system("sudo reboot")
else:
self._reset_state = ResetState.FAILED
def start_reset(self):
self._reset_state = ResetState.RESETTING
threading.Timer(0.1, self._do_erase).start()
def _update_state(self):
if self._reset_state != self._previous_reset_state:
self._previous_reset_state = self._reset_state
self._timeout_st = time.monotonic()
elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
exit(0)
def _render(self, rect: rl.Rectangle):
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
gui_text_box(text_rect, self._get_body_text(), 90)
button_height = 160
button_spacing = 50
button_top = rect.y + rect.height - button_height
button_width = (rect.width - button_spacing) / 2.0
if self._reset_state != ResetState.RESETTING:
if self._mode == ResetMode.RECOVER:
self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
elif self._mode == ResetMode.USER_RESET:
self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
if self._reset_state != ResetState.FAILED:
self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
else:
self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
return self._render_status
def _confirm(self):
if self._reset_state == ResetState.CONFIRM:
self.start_reset()
else:
self._reset_state = ResetState.CONFIRM
def _get_body_text(self):
if self._reset_state == ResetState.CONFIRM:
return "Are you sure you want to reset your device?"
if self._reset_state == ResetState.RESETTING:
return "Resetting device...\nThis may take up to a minute."
if self._reset_state == ResetState.FAILED:
return "Reset failed. Reboot to try again."
if self._mode == ResetMode.RECOVER:
return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
def main():
mode = ResetMode.USER_RESET
if len(sys.argv) > 1:
if sys.argv[1] == '--recover':
mode = ResetMode.RECOVER
elif sys.argv[1] == "--format":
mode = ResetMode.FORMAT
gui_app.init_window("System Reset", 20)
reset = Reset(mode)
if mode == ResetMode.FORMAT:
reset.start_reset()
for should_render in gui_app.render():
if should_render:
if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
break
if __name__ == "__main__":
main()
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env python3
import os
import re
import threading
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
from enum import IntEnum
import shutil
import pyray as rl
from cereal import log
from openpilot.common.utils import run_cmd
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
NetworkType = log.DeviceState.NetworkType
MARGIN = 50
TITLE_FONT_SIZE = 90
TITLE_FONT_WEIGHT = FontWeight.MEDIUM
NEXT_BUTTON_WIDTH = 310
BODY_FONT_SIZE = 80
BUTTON_HEIGHT = 160
BUTTON_SPACING = 50
OPENPILOT_URL = "https://openpilot.comma.ai"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
CONTINUE = """#!/usr/bin/env bash
cd /data/openpilot
exec ./launch_openpilot.sh
"""
class SetupState(IntEnum):
LOW_VOLTAGE = 0
GETTING_STARTED = 1
NETWORK_SETUP = 2
SOFTWARE_SELECTION = 3
CUSTOM_SOFTWARE = 4
DOWNLOADING = 5
DOWNLOAD_FAILED = 6
CUSTOM_SOFTWARE_WARNING = 7
class Setup(Widget):
def __init__(self):
super().__init__()
self.state = SetupState.GETTING_STARTED
self.network_check_thread = None
self.network_connected = threading.Event()
self.wifi_connected = threading.Event()
self.stop_network_check_thread = threading.Event()
self.failed_url = ""
self.failed_reason = ""
self.download_url = ""
self.download_progress = 0
self.download_thread = None
self.wifi_ui = WifiManagerUI(WifiManager())
self.keyboard = Keyboard()
self.selected_radio = None
self.warning = gui_app.texture("icons/warning.png", 150, 150)
self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=rl.Color(255, 89, 79, 255), text_padding=20)
self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._software_selection_continue_button.set_enabled(False)
self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_padding=20)
self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._network_setup_continue_button.set_enabled(False)
self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._custom_software_warning_continue_button.set_enabled(False)
self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=rl.Color(255, 89, 79, 255),
text_padding=60)
self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
+ "⚠️ It has not been tested by comma.\n\n"
+ "⚠️ It may not comply with relevant safety standards.\n\n"
+ "⚠️ It may cause damage to your device and/or vehicle.\n\n"
+ "If you'd like to proceed, use https://flash.comma.ai "
+ "to restore your device to a factory state later.",
68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
try:
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
voltage = float(f.read().strip()) / 1000.0
if voltage < 7:
self.state = SetupState.LOW_VOLTAGE
except (FileNotFoundError, ValueError):
self.state = SetupState.LOW_VOLTAGE
def _render(self, rect: rl.Rectangle):
if self.state == SetupState.LOW_VOLTAGE:
self.render_low_voltage(rect)
elif self.state == SetupState.GETTING_STARTED:
self.render_getting_started(rect)
elif self.state == SetupState.NETWORK_SETUP:
self.render_network_setup(rect)
elif self.state == SetupState.SOFTWARE_SELECTION:
self.render_software_selection(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
self.render_custom_software_warning(rect)
elif self.state == SetupState.CUSTOM_SOFTWARE:
self.render_custom_software()
elif self.state == SetupState.DOWNLOADING:
self.render_downloading(rect)
elif self.state == SetupState.DOWNLOAD_FAILED:
self.render_download_failed(rect)
def _low_voltage_continue_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _custom_software_warning_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _custom_software_warning_continue_button_callback(self):
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def _getting_started_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _software_selection_back_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _software_selection_continue_button_callback(self):
if self._software_selection_openpilot_button.selected:
self.use_openpilot()
else:
self.state = SetupState.CUSTOM_SOFTWARE_WARNING
def _download_failed_startover_button_callback(self):
self.state = SetupState.GETTING_STARTED
def _network_setup_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _network_setup_continue_button_callback(self):
self.stop_network_check_thread.set()
if self._software_selection_openpilot_button.selected:
self.download(OPENPILOT_URL)
else:
self.state = SetupState.CUSTOM_SOFTWARE
def render_low_voltage(self, rect: rl.Rectangle):
rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
button_width = (rect.width - MARGIN * 3) / 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
def render_getting_started(self, rect: rl.Rectangle):
self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
BODY_FONT_SIZE * FONT_SCALE * 3))
btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
self._getting_started_button.render(btn_rect)
triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
def check_network_connectivity(self):
while not self.stop_network_check_thread.is_set():
if self.state == SetupState.NETWORK_SETUP:
try:
urllib.request.urlopen(OPENPILOT_URL, timeout=2)
self.network_connected.set()
if HARDWARE.get_network_type() == NetworkType.wifi:
self.wifi_connected.set()
else:
self.wifi_connected.clear()
except Exception:
self.network_connected.clear()
time.sleep(1)
def start_network_check(self):
if self.network_check_thread is None or not self.network_check_thread.is_alive():
self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
self.network_check_thread.start()
def close(self):
if self.network_check_thread is not None:
self.stop_network_check_thread.set()
self.network_check_thread.join()
def render_network_setup(self, rect: rl.Rectangle):
self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
self.wifi_ui.render(wifi_content_rect)
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
# Check network connectivity status
continue_enabled = self.network_connected.is_set()
self._network_setup_continue_button.set_enabled(continue_enabled)
continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
self._network_setup_continue_button.set_text(continue_text)
self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_software_selection(self, rect: rl.Rectangle):
self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
radio_height = 230
radio_spacing = 30
self._software_selection_continue_button.set_enabled(False)
openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
self._software_selection_openpilot_button.render(openpilot_rect)
if self._software_selection_openpilot_button.selected:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_custom_software_button.selected = False
custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
radio_height)
self._software_selection_custom_software_button.render(custom_rect)
if self._software_selection_custom_software_button.selected:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_openpilot_button.selected = False
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_downloading(self, rect: rl.Rectangle):
self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
TITLE_FONT_SIZE * FONT_SCALE))
def render_download_failed(self, rect: rl.Rectangle):
self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
self._download_failed_url_label.set_text(self.failed_url)
self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
self._download_failed_body_label.set_text(self.failed_reason)
self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
def render_custom_software_warning(self, rect: rl.Rectangle):
warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
button_width = (rect.width - MARGIN * 3) / 2
button_y = rect.height - MARGIN - BUTTON_HEIGHT
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
y_offset = rect.y + offset
self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 400, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
rl.end_scissor_mode()
self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
if offset < (rect.height - warn_rect.height):
self._custom_software_warning_continue_button.set_enabled(True)
self._custom_software_warning_continue_button.set_text("Continue")
def render_custom_software(self):
def handle_keyboard_result(result):
# Enter pressed
if result == 1:
url = self.keyboard.text
self.keyboard.clear()
if url:
self.download(url)
# Cancel pressed
elif result == 0:
self.state = SetupState.SOFTWARE_SELECTION
self.keyboard.reset(min_text_size=1)
self.keyboard.set_title("Enter URL", "for Custom Software")
gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
def use_openpilot(self):
if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
os.remove(VALID_CACHE_PATH)
with open(TMP_CONTINUE_PATH, "w") as f:
f.write(CONTINUE)
run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
else:
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
def download(self, url: str):
# autocomplete incomplete URLs
if re.match("^([^/.]+)/([^/]+)$", url):
url = f"https://installer.comma.ai/{url}"
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self.state = SetupState.DOWNLOADING
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _download_thread(self):
try:
import tempfile
fd, tmpfile = tempfile.mkstemp(prefix="installer_")
headers = {"User-Agent": USER_AGENT,
"X-openpilot-serial": HARDWARE.get_serial(),
"X-openpilot-device-type": HARDWARE.get_device_type()}
req = urllib.request.Request(self.download_url, headers=headers)
with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
block_size = 8192
while True:
buffer = response.read(block_size)
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
if total_size:
self.download_progress = int(downloaded * 100 / total_size)
is_elf = False
with open(tmpfile, 'rb') as f:
header = f.read(4)
is_elf = header == b'\x7fELF'
if not is_elf:
self.download_failed(self.download_url, "No custom software found at this URL.")
return
# AGNOS might try to execute the installer before this process exits.
# Therefore, important to close the fd before renaming the installer.
os.close(fd)
os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(self.download_url)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
except urllib.error.HTTPError as e:
if e.code == 409:
error_msg = e.read().decode("utf-8")
self.download_failed(self.download_url, error_msg)
except Exception:
error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
self.download_failed(self.download_url, error_msg)
def download_failed(self, url: str, reason: str):
self.failed_url = url
self.failed_reason = reason
self.state = SetupState.DOWNLOAD_FAILED
def main():
try:
gui_app.init_window("Setup", 20)
setup = Setup()
for should_render in gui_app.render():
if should_render:
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
setup.close()
except Exception as e:
print(f"Setup error: {e}")
finally:
gui_app.close()
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_text_box, gui_label
from openpilot.system.ui.widgets.network import WifiManagerUI
# Constants
MARGIN = 50
BUTTON_HEIGHT = 160
BUTTON_WIDTH = 400
PROGRESS_BAR_HEIGHT = 72
TITLE_FONT_SIZE = 80
BODY_FONT_SIZE = 65
BACKGROUND_COLOR = rl.BLACK
PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
class Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
self.progress_value = 0
self.progress_text = "Loading..."
self.show_reboot_button = False
self.process = None
self.update_thread = None
self.wifi_manager_ui = WifiManagerUI(WifiManager())
# Buttons
self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
def set_current_screen(self, screen: Screen):
self.current_screen = screen
def install_update(self):
self.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "Downloading..."
self.show_reboot_button = False
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
for line in self.process.stdout:
parts = line.strip().split(":")
if len(parts) == 2:
self.progress_text = parts[0]
try:
self.progress_value = int(float(parts[1]))
except ValueError:
pass
exit_code = self.process.wait()
if exit_code == 0:
HARDWARE.reboot()
else:
self.progress_text = "Update failed"
self.show_reboot_button = True
def render_prompt_screen(self, rect: rl.Rectangle):
# Title
title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
# Description
desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
"The download size is approximately 1GB.")
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
# Buttons at the bottom
button_y = rect.height - MARGIN - BUTTON_HEIGHT
button_width = (rect.width - MARGIN * 3) // 2
# WiFi button
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
self._wifi_button.render(wifi_button_rect)
# Install button
install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
self._install_button.render(install_button_rect)
def render_wifi_screen(self, rect: rl.Rectangle):
# Draw the Wi-Fi manager UI
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
rect.height - BUTTON_HEIGHT - MARGIN * 3)
rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
self.wifi_manager_ui.render(wifi_content_rect)
back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
self._back_button.render(back_button_rect)
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
# Progress bar
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
# Calculate the width of the progress chunk
progress_width = (bar_rect.width * self.progress_value) / 100
if progress_width > 0:
progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
# Show reboot button if needed
if self.show_reboot_button:
reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
self._reboot_button.render(reboot_rect)
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self.render_wifi_screen(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
def main():
if len(sys.argv) < 3:
print("Usage: updater.py <updater_path> <manifest_path>")
sys.exit(1)
updater_path = sys.argv[1]
manifest_path = sys.argv[2]
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
# Make sure we clean up even if there's an error
gui_app.close()
if __name__ == "__main__":
main()
+7 -165
View File
@@ -1,172 +1,14 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from openpilot.system.hardware import HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from openpilot.system.ui.lib.wifi_manager import WifiManager
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_text_box, gui_label
from openpilot.system.ui.widgets.network import WifiManagerUI
# Constants
MARGIN = 50
BUTTON_HEIGHT = 160
BUTTON_WIDTH = 400
PROGRESS_BAR_HEIGHT = 72
TITLE_FONT_SIZE = 80
BODY_FONT_SIZE = 65
BACKGROUND_COLOR = rl.BLACK
PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
class Screen(IntEnum):
PROMPT = 0
WIFI = 1
PROGRESS = 2
class Updater(Widget):
def __init__(self, updater_path, manifest_path):
super().__init__()
self.updater = updater_path
self.manifest = manifest_path
self.current_screen = Screen.PROMPT
self.progress_value = 0
self.progress_text = "Loading..."
self.show_reboot_button = False
self.process = None
self.update_thread = None
self.wifi_manager_ui = WifiManagerUI(WifiManager())
# Buttons
self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
def set_current_screen(self, screen: Screen):
self.current_screen = screen
def install_update(self):
self.set_current_screen(Screen.PROGRESS)
self.progress_value = 0
self.progress_text = "Downloading..."
self.show_reboot_button = False
# Start the update process in a separate thread
self.update_thread = threading.Thread(target=self._run_update_process)
self.update_thread.daemon = True
self.update_thread.start()
def _run_update_process(self):
# TODO: just import it and run in a thread without a subprocess
cmd = [self.updater, "--swap", self.manifest]
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, universal_newlines=True)
for line in self.process.stdout:
parts = line.strip().split(":")
if len(parts) == 2:
self.progress_text = parts[0]
try:
self.progress_value = int(float(parts[1]))
except ValueError:
pass
exit_code = self.process.wait()
if exit_code == 0:
HARDWARE.reboot()
else:
self.progress_text = "Update failed"
self.show_reboot_button = True
def render_prompt_screen(self, rect: rl.Rectangle):
# Title
title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
# Description
desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
"The download size is approximately 1GB.")
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
# Buttons at the bottom
button_y = rect.height - MARGIN - BUTTON_HEIGHT
button_width = (rect.width - MARGIN * 3) // 2
# WiFi button
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
self._wifi_button.render(wifi_button_rect)
# Install button
install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
self._install_button.render(install_button_rect)
def render_wifi_screen(self, rect: rl.Rectangle):
# Draw the Wi-Fi manager UI
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
rect.height - BUTTON_HEIGHT - MARGIN * 3)
rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
self.wifi_manager_ui.render(wifi_content_rect)
back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
self._back_button.render(back_button_rect)
def render_progress_screen(self, rect: rl.Rectangle):
title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
# Progress bar
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
# Calculate the width of the progress chunk
progress_width = (bar_rect.width * self.progress_value) / 100
if progress_width > 0:
progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
# Show reboot button if needed
if self.show_reboot_button:
reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
self._reboot_button.render(reboot_rect)
def _render(self, rect: rl.Rectangle):
if self.current_screen == Screen.PROMPT:
self.render_prompt_screen(rect)
elif self.current_screen == Screen.WIFI:
self.render_wifi_screen(rect)
elif self.current_screen == Screen.PROGRESS:
self.render_progress_screen(rect)
from openpilot.system.ui.lib.application import gui_app
import openpilot.system.ui.tici_updater as tici_updater
import openpilot.system.ui.mici_updater as mici_updater
def main():
if len(sys.argv) < 3:
print("Usage: updater.py <updater_path> <manifest_path>")
sys.exit(1)
updater_path = sys.argv[1]
manifest_path = sys.argv[2]
try:
gui_app.init_window("System Update")
updater = Updater(updater_path, manifest_path)
for should_render in gui_app.render():
if should_render:
updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
finally:
# Make sure we clean up even if there's an error
gui_app.close()
if gui_app.big_ui():
tici_updater.main()
else:
mici_updater.main()
if __name__ == "__main__":
+215 -3
View File
@@ -2,7 +2,15 @@ import abc
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS
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() # type: ignore
class DialogResult(IntEnum):
@@ -23,6 +31,7 @@ class Widget(abc.ABC):
self._touch_valid_callback: Callable[[], bool] | None = None
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False
self.__was_awake = True
@property
def rect(self) -> rl.Rectangle:
@@ -71,7 +80,7 @@ class Widget(abc.ABC):
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect.x, self._rect.y = x, y
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
if changed:
self._update_layout_rects()
@@ -94,7 +103,7 @@ class Widget(abc.ABC):
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
if self.enabled:
if self.enabled and self.__was_awake:
for mouse_event in gui_app.mouse_events:
if not self._multi_touch and mouse_event.slot != 0:
continue
@@ -106,6 +115,7 @@ class Widget(abc.ABC):
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
self.__tracking_is_pressed[mouse_event.slot] = True
self._handle_mouse_event(mouse_event)
# Callback such as scroll panel signifies user is scrolling
elif not self._touch_valid():
@@ -113,6 +123,7 @@ class Widget(abc.ABC):
self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released:
self._handle_mouse_event(mouse_event)
if self.__is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self._handle_mouse_release(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = False
@@ -122,10 +133,14 @@ class Widget(abc.ABC):
elif rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
if self.__tracking_is_pressed[mouse_event.slot]:
self.__is_pressed[mouse_event.slot] = True
self._handle_mouse_event(mouse_event)
# Mouse/touch left our rect but may come back into focus later
elif not rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self.__is_pressed[mouse_event.slot] = False
self._handle_mouse_event(mouse_event)
self.__was_awake = device.awake
return ret
@@ -149,9 +164,206 @@ class Widget(abc.ABC):
self._click_callback()
return False
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
"""Optionally handle mouse events. This is called before rendering."""
# Default implementation does nothing, can be overridden by subclasses
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
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
# 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
elif hasattr(self, '_scroll_panel'):
scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
if 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()
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()
+116 -3
View File
@@ -3,9 +3,10 @@ from enum import IntEnum
import pyray as rl
from openpilot.system.ui.lib.application import FontWeight, MousePos
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.label import Label, UnifiedLabel
from openpilot.common.filter_simple import FirstOrderFilter
class ButtonStyle(IntEnum):
@@ -175,9 +176,121 @@ class IconButton(Widget):
def __init__(self, texture: rl.Texture):
super().__init__()
self._texture = texture
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height))
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, rect: rl.Rectangle):
color = rl.Color(180, 180, 180, 150) if self.is_pressed else rl.WHITE
color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE
if not self.enabled:
color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(self._texture, int(draw_x), int(draw_y), color)
class SmallCircleIconButton(Widget):
def __init__(self, icon_txt: rl.Texture):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, 100, 100))
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._icon_bg_txt = gui_app.texture("icons_mici/setup/small_button.png", 100, 100)
self._icon_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_button_pressed.png", 100, 100)
self._icon_txt = icon_txt
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), 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), 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)
+1 -1
View File
@@ -6,7 +6,7 @@ from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller_tici import Scroller
OUTER_MARGIN = 200
RICH_OUTER_MARGIN = 100
+508 -3
View File
@@ -1,14 +1,15 @@
from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
from typing import Union
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.utils import GuiStyleContext
from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget
ICON_PADDING = 15
@@ -20,6 +21,171 @@ def _resolve_value(value, default=""):
return value if value is not None else default
class ScrollState(IntEnum):
STARTING = 0
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), 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), 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.Color(0, 0, 0, 0), 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.Color(0, 0, 0, 0))
rl.end_scissor_mode()
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
@@ -65,6 +231,7 @@ def gui_label(
}.get(alignment_vertical, 0)
# Draw the text in the specified rectangle
# TODO: add wrapping and proper centering for multiline text
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
@@ -76,11 +243,12 @@ def gui_text_box(
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
font_weight: FontWeight = FontWeight.NORMAL,
line_scale: float = 1.0,
):
styles = [
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
@@ -107,6 +275,7 @@ class Label(Widget):
text_color: rl.Color = DEFAULT_TEXT_COLOR,
icon: Union[rl.Texture, None] = None,
elide_right: bool = False,
line_scale=1.0,
):
super().__init__()
@@ -119,6 +288,7 @@ class Label(Widget):
self._text_color = text_color
self._icon = icon
self._elide_right = elide_right
self._line_scale = line_scale
self._text = text
self.set_text(text)
@@ -217,4 +387,339 @@ class Label(Widget):
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
text_pos.y += text_size.y or self._font_size * FONT_SCALE
text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale
class UnifiedLabel(Widget):
"""
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
Supports:
- Emoji rendering
- Text wrapping
- Automatic eliding (single-line or multiline)
- Proper multiline vertical alignment
- Height calculation for layout purposes
"""
def __init__(self,
text: str | Callable[[], str],
font_size: int = DEFAULT_TEXT_SIZE,
font_weight: FontWeight = FontWeight.NORMAL,
text_color: rl.Color = DEFAULT_TEXT_COLOR,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
text_padding: int = 0,
max_width: int | None = None,
elide: bool = True,
wrap_text: bool = True,
line_height: float = 1.0,
letter_spacing: float = 0.0):
super().__init__()
self._text = text
self._font_size = font_size
self._font_weight = font_weight
self._font = gui_app.font(self._font_weight)
self._text_color = text_color
self._alignment = alignment
self._alignment_vertical = alignment_vertical
self._text_padding = text_padding
self._max_width = max_width
self._elide = elide
self._wrap_text = wrap_text
self._line_height = line_height * 0.9
self._letter_spacing = letter_spacing # 0.1 = 10%
self._spacing_pixels = font_size * letter_spacing
# Cached data
self._cached_text: str | None = None
self._cached_wrapped_lines: list[str] = []
self._cached_line_sizes: list[rl.Vector2] = []
self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
self._cached_total_height: float | None = None
self._cached_width: int = -1
# If max_width is set, initialize rect size for Scroller support
if max_width is not None:
self._rect.width = max_width
self._rect.height = self.get_content_height(max_width)
def set_text(self, text: str | Callable[[], str]):
"""Update the text content."""
self._text = text
self._cached_text = None # Invalidate cache
@property
def text(self) -> str:
"""Get the current text content."""
return str(_resolve_value(self._text))
def set_text_color(self, color: rl.Color):
"""Update the text color."""
self._text_color = color
def set_color(self, color: rl.Color):
"""Update the text color (alias for set_text_color)."""
self.set_text_color(color)
def set_font_size(self, size: int):
"""Update the font size."""
self._font_size = size
self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
self._cached_text = None # Invalidate cache
def set_letter_spacing(self, letter_spacing: float):
"""Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
self._letter_spacing = letter_spacing
self._spacing_pixels = self._font_size * letter_spacing
self._cached_text = None # Invalidate cache
def set_font_weight(self, font_weight: FontWeight):
"""Update the font weight."""
if self._font_weight != font_weight:
self._font_weight = font_weight
self._font = gui_app.font(self._font_weight)
self._cached_text = None # Invalidate cache
def set_alignment(self, alignment: int):
"""Update the horizontal text alignment."""
self._alignment = alignment
def set_alignment_vertical(self, alignment_vertical: int):
"""Update the vertical text alignment."""
self._alignment_vertical = alignment_vertical
def set_max_width(self, max_width: int | None):
"""Set the maximum width constraint for wrapping/eliding."""
if self._max_width != max_width:
self._max_width = max_width
self._cached_text = None # Invalidate cache
# Update rect size for Scroller support
if max_width is not None:
self._rect.width = max_width
self._rect.height = self.get_content_height(max_width)
def _update_text_cache(self, available_width: int):
"""Update cached text processing data."""
text = self.text
# Check if cache is still valid
if (self._cached_text == text and
self._cached_width == available_width and
self._cached_wrapped_lines):
return
self._cached_text = text
self._cached_width = available_width
# Determine wrapping width
content_width = available_width - (self._text_padding * 2)
if content_width <= 0:
content_width = 1
# Wrap text if enabled
if self._wrap_text:
self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
else:
# Split by newlines but don't wrap
self._cached_wrapped_lines = text.split('\n') if text else [""]
# Elide lines if needed (for width constraint)
self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
# Process each line: measure and find emojis
self._cached_line_sizes = []
self._cached_line_emojis = []
for line in self._cached_wrapped_lines:
emojis = find_emoji(line)
self._cached_line_emojis.append(emojis)
# Empty lines should still have height (use font size as line height)
if not line:
size = rl.Vector2(0, self._font_size * FONT_SCALE)
else:
size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
self._cached_line_sizes.append(size)
# Calculate total height
# Each line contributes its measured height * line_height (matching Label's behavior)
# This includes spacing to the next line
if self._cached_line_sizes:
# Match the rendering logic: first line doesn't get line_height scaling
total_height = 0.0
for idx, size in enumerate(self._cached_line_sizes):
if idx == 0:
total_height += size.y
else:
total_height += size.y * self._line_height
self._cached_total_height = total_height
else:
self._cached_total_height = 0.0
def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
"""Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
if not self._elide and not force:
return line
text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
if text_size.x <= max_width and not force:
return line
ellipsis = "..."
# If force=True and line fits, just append ellipsis without truncating
if force and text_size.x <= max_width:
ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
if text_size.x + ellipsis_size.x <= max_width:
return line + ellipsis
# If line + ellipsis doesn't fit, need to truncate
# Fall through to binary search below
left, right = 0, len(line)
while left < right:
mid = (left + right) // 2
candidate = line[:mid] + ellipsis
candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
if candidate_size.x <= max_width:
left = mid + 1
else:
right = mid
return line[:left - 1] + ellipsis if left > 0 else ellipsis
def get_content_height(self, max_width: int) -> float:
"""
Returns the height needed for text at given max_width.
Similar to HtmlRenderer.get_total_height().
"""
# Use max_width if provided, otherwise use self._max_width or a default
width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
self._update_text_cache(width)
if self._cached_total_height is not None:
return self._cached_total_height
return 0.0
def _render(self, rect: rl.Rectangle):
"""Render the label."""
if rect.width <= 0 or rect.height <= 0:
return
# Determine available width
available_width = rect.width
if self._max_width is not None:
available_width = min(available_width, self._max_width)
# Update text cache
self._update_text_cache(int(available_width))
if not self._cached_wrapped_lines:
return
# Calculate which lines fit in the available height
visible_lines: list[str] = []
visible_sizes: list[rl.Vector2] = []
visible_emojis: list[list[tuple[int, int, str]]] = []
current_height = 0.0
broke_early = False
for line, size, emojis in zip(
self._cached_wrapped_lines,
self._cached_line_sizes,
self._cached_line_emojis,
strict=True):
# Calculate height needed for this line
# Each line contributes its height * line_height (matching Label's behavior)
line_height_needed = size.y * self._line_height
# Check if this line fits
if current_height + line_height_needed > rect.height:
# This line doesn't fit
if len(visible_lines) == 0:
# First line doesn't fit by height - still show it (will be clipped by scissor if needed)
# Continue to add this line below
pass
else:
# We have visible lines and this one doesn't fit - mark that we broke early
broke_early = True
break
visible_lines.append(line)
visible_sizes.append(size)
visible_emojis.append(emojis)
current_height += line_height_needed
# If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
if broke_early and len(visible_lines) > 0 and self._elide:
content_width = int(available_width - (self._text_padding * 2))
if content_width <= 0:
content_width = 1
last_line_idx = len(visible_lines) - 1
last_line = visible_lines[last_line_idx]
# Force elide the last line to show "..." even if it fits in width (to indicate more content)
elided = self._elide_line(last_line, content_width, force=True)
visible_lines[last_line_idx] = elided
visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
if not visible_lines:
return
# Calculate total visible text block height
# First line is not changed by line_height scaling
total_visible_height = 0.0
for idx, size in enumerate(visible_sizes):
if idx == 0:
total_visible_height += size.y
else:
total_visible_height += size.y * self._line_height
# Calculate vertical alignment offset
if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
start_y = rect.y
elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
start_y = rect.y + rect.height - total_visible_height
else: # TEXT_ALIGN_MIDDLE
start_y = rect.y + (rect.height - total_visible_height) / 2
# Render each line
current_y = start_y
for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
# Calculate horizontal position
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
line_x = rect.x + self._text_padding
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
line_x = rect.x + (rect.width - size.x) / 2
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
line_x = rect.x + rect.width - size.x - self._text_padding
else:
line_x = rect.x + self._text_padding
# Render line with emojis
line_pos = rl.Vector2(line_x, current_y)
prev_index = 0
for start, end, emoji in emojis:
# Draw text before emoji
text_before = line[prev_index:start]
if text_before:
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
line_pos.x += width_before.x
# Draw emoji
tex = emoji_tex(emoji)
emoji_scale = self._font_size / tex.height * FONT_SCALE
rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
# Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
# Draw remaining text after last emoji
text_after = line[prev_index:]
if text_after:
rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
# Move to next line (if not last line)
if idx < len(visible_lines) - 1:
# Use current line's height * line_height for spacing to next line
current_y += size.y * self._line_height
+388
View File
@@ -0,0 +1,388 @@
from enum import IntEnum
import pyray as rl
import numpy as np
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.common.filter_simple import BounceFilter
CHAR_FONT_SIZE = 42
CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2
SELECTED_CHAR_FONT_SIZE = 128
CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this
NUMBER_LAYER_SWITCH_FONT_SIZE = 24
KEYBOARD_COLUMN_PADDING = 33
KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in
KEY_TOUCH_AREA_OFFSET = 10 # px
KEY_DRAG_HYSTERESIS = 5 # px
KEY_MIN_ANIMATION_TIME = 0.075 # s
DEBUG = False
ANIMATION_SCALE = 0.65
def zip_repeat(a, b):
la, lb = len(a), len(b)
for i in range(max(la, lb)):
yield (a[i] if i < la else a[-1],
b[i] if i < lb else b[-1])
def fast_euclidean_distance(dx, dy):
# https://en.wikibooks.org/wiki/Algorithms/Distance_approximations
max_d, min_d = abs(dx), abs(dy)
if max_d < min_d:
max_d, min_d = min_d, max_d
return 0.941246 * max_d + 0.41 * min_d
class Key(Widget):
def __init__(self, char: str):
super().__init__()
self.char = char
self._font = gui_app.font(FontWeight.SEMI_BOLD)
self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._color = rl.Color(255, 255, 255, 255)
self._position_initialized = False
self.original_position = rl.Vector2(0, 0)
def set_position(self, x: float, y: float, smooth: bool = True):
# TODO: swipe up from NavWidget has the keys lag behind other elements a bit
if not self._position_initialized:
self._x_filter.x = x
self._y_filter.x = 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._position_initialized = True
if not smooth:
self._x_filter.x = x
self._y_filter.x = y
self._rect.x = self._x_filter.update(x)
self._rect.y = self._y_filter.update(y)
def set_alpha(self, alpha: float):
self._alpha_filter.update(alpha)
def get_position(self) -> tuple[float, float]:
return self._rect.x, self._rect.y
def _update_state(self):
self._color.a = min(int(255 * self._alpha_filter.x), 255)
def _render(self, _):
# center char at rect position
text_size = measure_text_cached(self._font, self.char, self._get_font_size())
x = self._rect.x + self._rect.width / 2 - text_size.x / 2
y = self._rect.y + self._rect.height / 2 - text_size.y / 2
rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color)
if DEBUG:
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
def set_font_size(self, size: float):
self._size_filter.update(size)
def _get_font_size(self) -> int:
return int(round(self._size_filter.x))
class SmallKey(Key):
def __init__(self, chars: str):
super().__init__(chars)
self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
def set_font_size(self, size: float):
self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE))
class IconKey(Key):
def __init__(self, icon: str, vertical_align: str = "center", char: str = ""):
super().__init__(char)
self._icon = gui_app.texture(icon, 38, 38)
self._vertical_align = vertical_align
def set_icon(self, icon: str):
self._icon = gui_app.texture(icon, 38, 38)
def _render(self, _):
scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5])
if self._vertical_align == "center":
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2,
self._rect.y + (self._rect.height - self._icon.height * scale) / 2,
self._icon.width * scale, self._icon.height * scale)
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
elif self._vertical_align == "bottom":
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y,
self._icon.width * scale, self._icon.height * scale)
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
if DEBUG:
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
class CapsState(IntEnum):
LOWER = 0
UPPER = 1
LOCK = 2
class MiciKeyboard(Widget):
def __init__(self):
super().__init__()
lower_chars = [
"qwertyuiop",
"asdfghjkl",
"zxcvbnm",
]
upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars]
special_chars = [
"1234567890",
"-/:;()$&@\"",
"~.,?!'#%",
]
super_special_chars = [
"1234567890",
"`[]{}^*+=_",
"\\|<>¥€£•",
]
self._lower_keys = [[Key(char) for char in row] for row in lower_chars]
self._upper_keys = [[Key(char) for char in row] for row in upper_chars]
self._special_keys = [[Key(char) for char in row] for row in special_chars]
self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
# control keys
self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom")
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png")
# these two are in different places on some layouts
self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123")
self._abc_key = SmallKey("abc")
self._super_special_key = SmallKey("#+=")
# insert control keys
for keys in (self._lower_keys, self._upper_keys):
keys[2].insert(0, self._caps_key)
keys[2].append(self._123_key)
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
keys[1].append(self._space_key)
for keys in (self._special_keys, self._super_special_keys):
keys[2].append(self._abc_key)
self._special_keys[2].insert(0, self._super_special_key)
self._super_special_keys[2].insert(0, self._123_key2)
# set initial keys
self._current_keys: list[list[Key]] = []
self._set_keys(self._lower_keys)
self._caps_state = CapsState.LOWER
self._initialized = False
self._load_images()
self._closest_key: tuple[Key | None, float] = None, float('inf')
self._selected_key_t: float | None = None # time key was initially selected
self._unselect_key_t: float | None = None # time to unselect key after release
self._dragging_on_keyboard = False
self._text: str = ""
self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
def get_candidate_character(self) -> str:
# return str of character about to be added to text
key = self._closest_key[0]
return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else ""
def get_keyboard_height(self) -> int:
return int(self._txt_bg.height)
def _load_images(self):
self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False)
def _set_keys(self, keys: list[list[Key]]):
# inherit previous keys' positions to fix switching animation
for current_row, row in zip(self._current_keys, keys, strict=False):
# not all layouts have the same number of keys
for current_key, key in zip_repeat(current_row, row):
current_pos = current_key.get_position()
key.set_position(current_pos[0], current_pos[1], smooth=False)
self._current_keys = keys
def set_text(self, text: str):
self._text = text
def text(self) -> str:
return self._text
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
keyboard_pos_y = self._rect.y + self._rect.height - self._txt_bg.height
if mouse_event.left_pressed:
if mouse_event.pos.y > keyboard_pos_y:
self._dragging_on_keyboard = True
elif mouse_event.left_released:
self._dragging_on_keyboard = False
if mouse_event.left_down and self._dragging_on_keyboard:
self._closest_key = self._get_closest_key()
if self._selected_key_t is None:
self._selected_key_t = rl.get_time()
# unselect key temporarily if mouse goes above keyboard
if mouse_event.pos.y <= keyboard_pos_y:
self._closest_key = (None, float('inf'))
if DEBUG:
print('HANDLE MOUSE EVENT', mouse_event, self._closest_key[0].char if self._closest_key[0] else 'None')
def _get_closest_key(self) -> tuple[Key | None, float]:
closest_key: tuple[Key | None, float] = (None, float('inf'))
for row in self._current_keys:
for key in row:
mouse_pos = gui_app.last_mouse_event.pos
# approximate distance for comparison is accurate enough
dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y)
if dist < closest_key[1]:
if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS:
closest_key = (key, dist)
return closest_key
def _set_uppercase(self, cycle: bool):
self._set_keys(self._upper_keys if cycle else self._lower_keys)
if not cycle:
self._caps_state = CapsState.LOWER
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png")
else:
if self._caps_state == CapsState.LOWER:
self._caps_state = CapsState.UPPER
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png")
elif self._caps_state == CapsState.UPPER:
self._caps_state = CapsState.LOCK
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png")
else:
self._set_uppercase(False)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._closest_key[0] is not None:
if self._closest_key[0] == self._caps_key:
self._set_uppercase(True)
elif self._closest_key[0] in (self._123_key, self._123_key2):
self._set_keys(self._special_keys)
elif self._closest_key[0] == self._abc_key:
self._set_uppercase(False)
elif self._closest_key[0] == self._super_special_key:
self._set_keys(self._super_special_keys)
else:
self._text += self._closest_key[0].char
# Reset caps state
if self._caps_state == CapsState.UPPER:
self._set_uppercase(False)
# ensure minimum selected animation time
key_selected_dt = rl.get_time() - (self._selected_key_t or 0)
cur_t = rl.get_time()
self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t
def backspace(self):
if self._text:
self._text = self._text[:-1]
def space(self):
self._text += ' '
def _update_state(self):
# unselect key after animation plays
if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t:
self._closest_key = (None, float('inf'))
self._unselect_key_t = None
self._selected_key_t = None
def _lay_out_keys(self, bg_x, bg_y, keys: list[list[Key]]):
key_rect = rl.Rectangle(bg_x, bg_y, self._txt_bg.width, self._txt_bg.height)
for row_idx, row in enumerate(keys):
padding = KEYBOARD_ROW_PADDING[row_idx]
step_y = (key_rect.height - 2 * KEYBOARD_COLUMN_PADDING) / (len(keys) - 1)
for key_idx, key in enumerate(row):
key_x = key_rect.x + padding + key_idx * ((key_rect.width - 2 * padding) / (len(row) - 1))
key_y = key_rect.y + KEYBOARD_COLUMN_PADDING + row_idx * step_y
if self._closest_key[0] is None:
key.set_alpha(1.0)
key.set_font_size(CHAR_FONT_SIZE)
elif key == self._closest_key[0]:
# push key up with a max and inward so user can see key easier
key_y = max(key_y - 120, 40)
key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100])
key.set_alpha(1.0)
key.set_font_size(SELECTED_CHAR_FONT_SIZE)
# draw black circle behind selected key
rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2),
SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, 225), rl.BLANK)
else:
# move other keys away from selected key a bit
dx = key.original_position.x - self._closest_key[0].original_position.x
dy = key.original_position.y - self._closest_key[0].original_position.y
distance_from_selected_key = fast_euclidean_distance(dx, dy)
inv = 1 / (distance_from_selected_key or 1.0)
ux = dx * inv
uy = dy * inv
# NOTE: hardcode to 20 to get entire keyboard to move
push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0])
key_x += ux * push_pixels
key_y += uy * push_pixels
# TODO: slow enough to use an approximation or nah? also caching might work
font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE])
key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35])
key.set_alpha(key_alpha)
key.set_font_size(font_size)
# TODO: I like the push amount, so we should clip the pos inside the keyboard rect
key.set_position(key_x, key_y)
def _render(self, _):
# draw bg
bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2
bg_y = self._rect.y + self._rect.height - self._txt_bg.height
scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0)
src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height)
dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y,
self._txt_bg.width * scale, self._txt_bg.height)
rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE)
# draw keys
if not self._initialized:
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
self._lay_out_keys(bg_x, bg_y, keys)
self._initialized = True
self._lay_out_keys(bg_x, bg_y, self._current_keys)
for row in self._current_keys:
for key in row:
key.render()
+1 -1
View File
@@ -12,7 +12,7 @@ from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
+1 -1
View File
@@ -4,7 +4,7 @@ from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.widgets.scroller_tici import Scroller
# Constants
MARGIN = 50
+182 -21
View File
@@ -1,10 +1,21 @@
import pyray as rl
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
import numpy as np
from collections.abc import Callable
from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
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
ITEM_SPACING = 40
ITEM_SPACING = 20
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
ANIMATION_SCALE = 0.6
MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
DO_ZOOM = False
DO_JELLO = False
SCROLL_BAR = False
class LineSeparator(Widget):
@@ -23,21 +34,131 @@ class LineSeparator(Widget):
class Scroller(Widget):
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
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):
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.scroll_panel = GuiScrollPanel()
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._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
self._zoom_out_t: float = 0.0
self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
# when not pressed, snap to closest item to be center
self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
self._scroll_enabled: bool | Callable[[], bool] = True
self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80)
for item in items:
self.add_widget(item)
def set_reset_scroll_at_show(self, scroll: bool):
self._reset_scroll_at_show = scroll
def scroll_to(self, pos: float, smooth: bool = False):
# already there
if abs(pos) < 1:
return
# FIXME: the padding correction doesn't seem correct
scroll_offset = self.scroll_panel.get_offset() - pos + self._pad_end
if smooth:
self._scrolling_to = scroll_offset
else:
self.scroll_panel.set_offset(scroll_offset)
@property
def is_auto_scrolling(self) -> bool:
return self._scrolling_to is not None
def add_widget(self, item: Widget) -> None:
self._items.append(item)
item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
"""Set whether scrolling is enabled (does not affect widget enabled state)."""
self._scroll_enabled = enabled
def _update_state(self):
if DO_ZOOM:
if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY:
self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME
self._zoom_filter.update(0.85)
else:
if self._zoom_out_t is not None:
if rl.get_time() > self._zoom_out_t:
self._zoom_filter.update(1.0)
else:
self._zoom_filter.update(0.85)
# Cancel auto-scroll if user starts manually scrolling
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
if self._scrolling_to is not None:
self._scroll_filter.update(self._scrolling_to)
self.scroll_panel.set_offset(self._scroll_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()
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.update(self._rect, content_size)
if not self._snap_items:
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
closest_delta_pos = float('inf')
scroll_snap_idx: int | None = None
for idx, item in enumerate(visible_items):
if self._horizontal:
delta_pos = (item.rect.x + item.rect.width / 2) - center_pos
else:
delta_pos = (item.rect.y + item.rect.height / 2) - center_pos
if abs(delta_pos) < abs(closest_delta_pos):
closest_delta_pos = delta_pos
scroll_snap_idx = idx
if scroll_snap_idx is not None:
snap_item = visible_items[scroll_snap_idx]
if self.is_pressed:
# no snapping until released
self._scroll_snap_filter.x = 0
else:
# TODO: this doesn't handle two small buttons at the edges well
if self._horizontal:
snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10
snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10)
else:
snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10
snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
snap_delta_pos = max(snap_delta_pos, (self._rect.height - self.scroll_panel.get_offset() - content_size) / 10)
self._scroll_snap_filter.update(snap_delta_pos)
self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x)
return self.scroll_panel.get_offset()
def _render(self, _):
# TODO: don't draw items that are not in the viewport
@@ -49,38 +170,78 @@ class Scroller(Widget):
for i in range(1, len(visible_items)):
visible_items.insert(l - i, self._line_separator)
content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
if not self._pad_end:
content_height -= self._spacing
scroll = self.scroll_panel.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in visible_items)
content_size += self._spacing * (len(visible_items) - 1)
content_size += self._pad_start + self._pad_end
scroll_offset = self._get_scroll(visible_items, content_size)
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
cur_height = 0
for idx, item in enumerate(visible_items):
if not item.is_visible:
continue
self._item_pos_filter.update(scroll_offset)
# Nicely lay out items vertically
x = self._rect.x
y = self._rect.y + cur_height + self._spacing * (idx != 0)
cur_height += item.rect.height + self._spacing * (idx != 0)
cur_pos = 0
for idx, item in enumerate(visible_items):
spacing = self._spacing if (idx > 0) else self._pad_start
# Nicely lay out items horizontally/vertically
if self._horizontal:
x = self._rect.x + cur_pos + spacing
y = self._rect.y + (self._rect.height - item.rect.height) / 2
cur_pos += item.rect.width + spacing
else:
x = self._rect.x + (self._rect.width - item.rect.width) / 2
y = self._rect.y + cur_pos + spacing
cur_pos += item.rect.height + spacing
# Consider scroll
y += scroll
if self._horizontal:
x += scroll_offset
else:
y += scroll_offset
# Add some jello effect when scrolling
if DO_JELLO:
if self._horizontal:
cx = self._rect.x + self._rect.width / 2
jello_offset = scroll_offset - np.interp(x + item.rect.width / 2,
[self._rect.x, cx, self._rect.x + self._rect.width],
[self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x])
x -= np.clip(jello_offset, -20, 20)
else:
cy = self._rect.y + self._rect.height / 2
jello_offset = scroll_offset - np.interp(y + item.rect.height / 2,
[self._rect.y, cy, self._rect.y + self._rect.height],
[self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x])
y -= np.clip(jello_offset, -20, 20)
# Update item state
item.set_position(x, y)
item.set_position(round(x), round(y)) # round to prevent jumping when settling
item.set_parent_rect(self._rect)
# Scale each element around its own origin when scrolling
scale = self._zoom_filter.x
rl.rl_push_matrix()
rl.rl_scalef(scale, scale, 1.0)
rl.rl_translatef((1 - scale) * (x + item.rect.width / 2) / scale,
(1 - scale) * (y + item.rect.height / 2) / scale, 0)
item.render()
rl.rl_pop_matrix()
# Draw scroll indicator
if SCROLL_BAR and not self._horizontal and len(visible_items) > 0:
_real_content_size = content_size - self._rect.height + self._txt_scroll_indicator.height
scroll_bar_y = -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_scroll_indicator.height)
rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)
rl.end_scissor_mode()
def show_event(self):
super().show_event()
# Reset to top
self.scroll_panel.set_offset(0)
if self._reset_scroll_at_show:
self.scroll_to(self.scroll_panel.get_offset())
for item in self._items:
item.show_event()
+90
View File
@@ -0,0 +1,90 @@
import pyray as rl
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.widgets import Widget
ITEM_SPACING = 40
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
self._line_separator = LineSeparator() if line_separator else None
self._pad_end = pad_end
self.scroll_panel = GuiScrollPanel()
for item in items:
self.add_widget(item)
def add_widget(self, item: Widget) -> None:
self._items.append(item)
item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
def _render(self, _):
# TODO: don't draw items that are not in the viewport
visible_items = [item for item in self._items if item.is_visible]
# Add line separator between items
if self._line_separator is not None:
l = len(visible_items)
for i in range(1, len(visible_items)):
visible_items.insert(l - i, self._line_separator)
content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
if not self._pad_end:
content_height -= self._spacing
scroll = self.scroll_panel.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
cur_height = 0
for idx, item in enumerate(visible_items):
if not item.is_visible:
continue
# Nicely lay out items vertically
x = self._rect.x
y = self._rect.y + cur_height + self._spacing * (idx != 0)
cur_height += item.rect.height + self._spacing * (idx != 0)
# Consider scroll
y += scroll
# Update item state
item.set_position(x, y)
item.set_parent_rect(self._rect)
item.render()
rl.end_scissor_mode()
def show_event(self):
super().show_event()
# Reset to top
self.scroll_panel.set_offset(0)
for item in self._items:
item.show_event()
def hide_event(self):
super().hide_event()
for item in self._items:
item.hide_event()
+183
View File
@@ -0,0 +1,183 @@
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.common.filter_simple import FirstOrderFilter
class SmallSlider(Widget):
HORIZONTAL_PADDING = 8
CONFIRM_DELAY = 0.2
def __init__(self, title: str, confirm_callback: Callable | None = None):
# TODO: unify this with BigConfirmationDialogV2
super().__init__()
self._confirm_callback = confirm_callback
self._font = gui_app.font(FontWeight.DISPLAY)
self._load_assets()
self._drag_threshold = -self._rect.width // 2
# State
self._opacity = 1.0
self._confirmed_time = 0.0
self._confirm_callback_called = False # we keep dialog open by default, only call once
self._start_x_circle = 0.0
self._scroll_x_circle = 0.0
self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._is_dragging_circle = False
self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.MEDIUM, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9)
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_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
@property
def confirmed(self) -> bool:
return self._confirmed_time > 0.0
def reset(self):
# reset all slider state
self._is_dragging_circle = False
self._confirmed_time = 0.0
self._confirm_callback_called = False
def set_opacity(self, opacity: float):
self._opacity = opacity
@property
def slider_percentage(self):
activated_pos = -self._bg_txt.width + self._circle_bg_txt.width
return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0)
def _on_confirm(self):
if self._confirm_callback:
self._confirm_callback()
def _handle_mouse_event(self, mouse_event):
super()._handle_mouse_event(mouse_event)
if mouse_event.left_pressed:
# touch rect goes to the padding
circle_button_rect = rl.Rectangle(
self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2,
self._rect.y,
self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2,
self._rect.height,
)
if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
self._start_x_circle = mouse_event.pos.x
self._is_dragging_circle = True
elif mouse_event.left_released:
# swiped to left
if self._scroll_x_circle_filter.x < self._drag_threshold:
self._confirmed_time = rl.get_time()
self._is_dragging_circle = False
if self._is_dragging_circle:
self._scroll_x_circle = mouse_event.pos.x - self._start_x_circle
def _update_state(self):
super()._update_state()
# TODO: this math can probably be cleaned up to remove duplicate stuff
activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
if self._confirmed_time > 0:
# 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
elif not self._is_dragging_circle:
# reset back to right
self._scroll_x_circle_filter.update(0)
else:
# not activated yet, keep movement 1:1
self._scroll_x_circle_filter.x = self._scroll_x_circle
def _render(self, _):
# TODO: iOS text shimmering animation
white = rl.Color(255, 255, 255, int(255 * self._opacity))
bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
bg_txt_y = self._rect.y + (self._rect.height - self._bg_txt.height) / 2
rl.draw_texture_ex(self._bg_txt, rl.Vector2(bg_txt_x, bg_txt_y), 0.0, 1.0, white)
btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
if self._confirmed_time == 0.0 or self._scroll_x_circle > 0:
self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity)))
label_rect = rl.Rectangle(
self._rect.x + 20,
self._rect.y,
self._rect.width - self._circle_bg_txt.width - 20 * 3,
self._rect.height,
)
self._label.render(label_rect)
# circle and arrow
rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white)
arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
arrow_y = 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):
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True):
self._green = green
super().__init__(title, confirm_callback=confirm_callback)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115)
circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle"
self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115)
self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
class BigSlider(SmallSlider):
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.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
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_arrow_txt = self._icon
class RedBigSlider(BigSlider):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
self._circle_arrow_txt = self._icon