IQ.Pilot Prebuilt Release @ 27f668a

This commit is contained in:
IQ.Lvbs CI [bot]
2026-09-03 18:23:24 -05:00
commit b073c5182b
2554 changed files with 679696 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# ui
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
* set `BURN_IN=1` to get a burn-in heatmap version of the UI
* set `GRID=50` to show a 50-pixel alignment grid overlay
* set `MAGIC_DEBUG=1` to show every dropped frames (only on device)
* set `RECORD=1` to record the screen, output defaults to `output.mp4` but can be set with `RECORD_OUTPUT`
* set `IQPILOT_UI=0` to run the stock UI instead of the IQ.Pilot UI
* https://www.raylib.com/cheatsheet/cheatsheet.html
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
Style guide:
* All graphical elements should subclass [`Widget`](/system/ui/widgets/__init__.py).
* Prefer a stateful widget over a function for easy migration from QT
* All internal class variables and functions should be prefixed with `_`

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

@@ -0,0 +1,36 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import os
import pyray as rl
_UI_MODE_IQ = os.getenv("IQPILOT_UI", "1") == "1"
_PROBE_FS = 20
_PROBE_MARGIN = 10
_PROBE_COLOR = rl.Color(0x12, 0x97, 0x91, 0xFF)
class IQAppHooks:
"""IQ hooks mixed into GuiApplication: UI-mode flag + pointer debug readout."""
def __init__(self):
self._pointer_probe = os.getenv("SHOW_MOUSE_COORDS") == "1"
@staticmethod
def iqpilot_ui() -> bool:
return _UI_MODE_IQ
def set_show_mouse_coords(self, show: bool):
self._pointer_probe = show
@property
def pointer_probe_enabled(self) -> bool:
return self._pointer_probe
def draw_pointer_probe(self, font):
readout = f"X:{rl.get_mouse_x()}, Y:{rl.get_mouse_y()}"
width = rl.measure_text_ex(font, readout, _PROBE_FS, 0).x
canvas_w = self._scaled_width if self._scale != 1.0 else self._width
rl.draw_text_ex(font, readout, rl.Vector2(canvas_w - width - _PROBE_MARGIN, 6), _PROBE_FS, 0, _PROBE_COLOR)

View File

@@ -0,0 +1,108 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
IQ.Pilot drawing surface. Onroad renderers and settings widgets paint through
this facade instead of touching pyray directly, so the HUD has one vocabulary
for shapes/text/textures. Each call forwards verbatim to the backing library —
output is identical to a raw pyray call, only the call site reads in IQ terms.
"""
import pyray as _p
from iqpilot.system.ui.lib.text_measure import measure_text_cached as _measure
Rgba = _p.Color
Box = _p.Rectangle
Pt = _p.Vector2
WHITE = _p.Color(255, 255, 255, 255)
BLACK = _p.Color(0, 0, 0, 255)
RED = _p.Color(230, 41, 55, 255)
CLEAR = _p.Color(0, 0, 0, 0)
def shade(r: int, g: int, b: int, a: int = 255) -> Rgba:
return _p.Color(r, g, b, a)
def with_opacity(color: Rgba, alpha: float) -> Rgba:
r, g, b = (color.r, color.g, color.b) if hasattr(color, "r") else color[:3]
return _p.Color(r, g, b, int(alpha))
# --- filled / stroked shapes -------------------------------------------------
def panel(box: Box, roundness: float, segments: int, color: Rgba) -> None:
_p.draw_rectangle_rounded(box, roundness, segments, color)
def panel_outline(box: Box, roundness: float, segments: int, thickness: float, color: Rgba) -> None:
_p.draw_rectangle_rounded_lines_ex(box, roundness, segments, thickness, color)
def slab(x: float, y: float, w: float, h: float, color: Rgba) -> None:
_p.draw_rectangle(int(x), int(y), int(w), int(h), color)
def h_sweep(x: float, y: float, w: float, h: float, left: Rgba, right: Rgba) -> None:
_p.draw_rectangle_gradient_h(int(x), int(y), int(w), int(h), left, right)
def v_sweep(x: float, y: float, w: float, h: float, top: Rgba, bottom: Rgba) -> None:
_p.draw_rectangle_gradient_v(int(x), int(y), int(w), int(h), top, bottom)
def disc(cx: float, cy: float, radius: float, color: Rgba) -> None:
_p.draw_circle(int(cx), int(cy), radius, color)
def disc_at(center: Pt, radius: float, color: Rgba) -> None:
_p.draw_circle_v(center, radius, color)
def hoop(cx: float, cy: float, radius: float, color: Rgba) -> None:
_p.draw_circle_lines(int(cx), int(cy), radius, color)
def annulus(center: Pt, inner: float, outer: float, start: float, end: float, segments: int, color: Rgba) -> None:
_p.draw_ring(center, inner, outer, start, end, segments, color)
def oval(cx: float, cy: float, rx: float, ry: float, color: Rgba) -> None:
_p.draw_ellipse(int(cx), int(cy), rx, ry, color)
def hair(a: Pt, b: Pt, thickness: float, color: Rgba) -> None:
_p.draw_line_ex(a, b, thickness, color)
def hair_xy(x0: float, y0: float, x1: float, y1: float, color: Rgba) -> None:
_p.draw_line(int(x0), int(y0), int(x1), int(y1), color)
def wedge(a: Pt, b: Pt, c: Pt, color: Rgba) -> None:
_p.draw_triangle(a, b, c, color)
# --- text --------------------------------------------------------------------
def span(box_w, text: str, size: int, spacing: float = 0):
"""Measured extent of a text run (Vector2)."""
return _measure(box_w, text, size, spacing)
def glyphs(font, text: str, at: Pt, size: int, color: Rgba, spacing: float = 0) -> None:
_p.draw_text_ex(font, text, at, size, spacing, color)
def glyphs_centered(font, text: str, size: int, center: Pt, color: Rgba, spacing: float = 0) -> None:
extent = _measure(font, text, size, spacing)
_p.draw_text_ex(font, text, _p.Vector2(center.x - extent.x / 2, center.y - extent.y / 2), size, spacing, color)
# --- textures ----------------------------------------------------------------
def stamp(tex, x: float, y: float, tint: Rgba) -> None:
_p.draw_texture(tex, int(x), int(y), tint)
def stamp_scaled(tex, src: Box, dst: Box, origin: Pt, rotation: float, tint: Rgba) -> None:
_p.draw_texture_pro(tex, src, dst, origin, rotation, tint)

View File

@@ -0,0 +1,142 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import pyray as rl
def _hex(rgb: int, alpha: int = 0xFF) -> rl.Color:
return rl.Color((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, alpha)
class ink:
"""IQ settings palette, grouped by role."""
# surfaces
PANEL = _hex(0x393939)
PANEL_MUTED = _hex(0x272727)
PANEL_PRESSED = _hex(0x151515)
# accent (selection / "on")
ACCENT = _hex(0x1C65BA)
ACCENT_PRESSED = _hex(0x114E96)
ACCENT_FADED = _hex(0x25466B)
# text
TITLE = rl.WHITE
READOUT = _hex(0xAAAAAA)
CAPTION = _hex(0x808080)
DISABLED = _hex(0x585858)
# toggle knob
KNOB = rl.WHITE
KNOB_DISABLED = _hex(0x585858)
# chips / segmented controls
CHIP_PRESSED = _hex(0x696868)
FAINT = rl.Color(255, 255, 255, 0x33)
CLEAR = rl.Color(255, 255, 255, 0)
VOID = rl.Color(0, 0, 0, 0)
# dialog key buttons
KEY_ACTION = _hex(0x465BEA)
KEY_NEUTRAL = _hex(0x333333)
KEY_SUNKEN = _hex(0x1E1E1E)
OUTLINE = _hex(0x969696, 200)
# status colours (vehicle fingerprint legend etc.)
STATUS_GOOD = _hex(0x129791)
STATUS_INFO = _hex(0x0086E9)
STATUS_WARN = _hex(0xFFD500)
# push buttons
PUSH = _hex(0x34373E)
PUSH_PRESSED = _hex(0x464A52)
PUSH_DISABLED = _hex(0x121212)
PUSH_TEXT_DISABLED = _hex(0x5C5C5C)
class metrics:
"""Row and control geometry for the IQ settings surfaces."""
ROW = 170
GUTTER = 20
TITLE_FS = 50
CAPTION_FS = 40
CAPTION_DY = 150
TEXT_PAD = 20
CLOSE_BTN = 160
TOGGLE_H = 120
TOGGLE_W = int(TOGGLE_H * 2.2)
TOGGLE_TRACK_H = TOGGLE_H - 20
ACTION_W = 300
ROW_BTN_H = 120
WIDE_BTN_W = 800
WIDE_BTN_H = 150
class style:
"""Transitional facade for panels not yet ported to ink/metrics; shrink as ports land."""
ITEM_BASE_HEIGHT = metrics.ROW
ITEM_PADDING = metrics.GUTTER
ITEM_TEXT_FONT_SIZE = metrics.TITLE_FS
ITEM_DESC_FONT_SIZE = metrics.CAPTION_FS
ITEM_DESC_V_OFFSET = metrics.CAPTION_DY
ITEM_TEXT_VALUE_COLOR = ink.READOUT
CLOSE_BTN_SIZE = metrics.CLOSE_BTN
TEXT_PADDING = metrics.TEXT_PAD
TOGGLE_HEIGHT = metrics.TOGGLE_H
TOGGLE_WIDTH = metrics.TOGGLE_W
TOGGLE_BG_HEIGHT = metrics.TOGGLE_TRACK_H
BUTTON_ACTION_WIDTH = metrics.ACTION_W
BUTTON_HEIGHT = metrics.ROW_BTN_H
SIMPLE_BUTTON_WIDTH = metrics.WIDE_BTN_W
SIMPLE_BUTTON_HEIGHT = metrics.WIDE_BTN_H
BASE_BG_COLOR = ink.PANEL
ON_BG_COLOR = ink.ACCENT
OFF_BG_COLOR = ink.PANEL
ON_HOVER_BG_COLOR = ink.ACCENT_PRESSED
OFF_HOVER_BG_COLOR = ink.PANEL_PRESSED
DISABLED_ON_BG_COLOR = ink.ACCENT_FADED
DISABLED_OFF_BG_COLOR = ink.PANEL_MUTED
ITEM_TEXT_COLOR = ink.TITLE
ITEM_DISABLED_TEXT_COLOR = ink.DISABLED
ITEM_DESC_TEXT_COLOR = ink.CAPTION
TOGGLE_ON_COLOR = ink.ACCENT
TOGGLE_OFF_COLOR = ink.PANEL
TOGGLE_KNOB_COLOR = ink.KNOB
TOGGLE_DISABLED_ON_COLOR = ink.ACCENT_FADED
TOGGLE_DISABLED_OFF_COLOR = ink.PANEL_MUTED
TOGGLE_DISABLED_KNOB_COLOR = ink.KNOB_DISABLED
MBC_TRANSPARENT = ink.CLEAR
MBC_BG_CHECKED_ENABLED = ink.CHIP_PRESSED
MBC_DISABLED = ink.FAINT
OPTION_CONTROL_CONTAINER_BG = ink.PANEL
OPTION_CONTROL_BTN_ENABLED = ink.KNOB_DISABLED
OPTION_CONTROL_BTN_PRESSED = ink.CHIP_PRESSED
OPTION_CONTROL_BTN_DISABLED = ink.PANEL_MUTED
OPTION_CONTROL_TEXT_ENABLED = ink.TITLE
OPTION_CONTROL_TEXT_PRESSED = ink.TITLE
OPTION_CONTROL_TEXT_DISABLED = ink.DISABLED
BUTTON_PRIMARY_COLOR = ink.KEY_ACTION
BUTTON_NEUTRAL_GRAY = ink.KEY_NEUTRAL
BUTTON_DISABLED_BG_COLOR = ink.KEY_SUNKEN
TREE_DIALOG_TRANSPARENT = ink.VOID
TREE_DIALOG_SEARCH_BUTTON_PRESSED = ink.CHIP_PRESSED
TREE_DIALOG_SEARCH_BUTTON_BORDER = ink.OUTLINE
GREEN = ink.STATUS_GOOD
BLUE = ink.STATUS_INFO
YELLOW = ink.STATUS_WARN
BUTTON_ENABLED_OFF = ink.PUSH
BUTTON_OFF_PRESSED = ink.PUSH_PRESSED
BUTTON_DISABLED = ink.PUSH_DISABLED
BUTTON_TEXT_DISABLED = ink.PUSH_TEXT_DISABLED

View File

@@ -0,0 +1,9 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
from iqpilot.system.ui.iqwidgets.widgets.list_view import IQButtonAction
class WideButtonAction(IQButtonAction):
def get_width_hint(self):
return super().get_width_hint() + 1

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

View File

@@ -0,0 +1,30 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import math
import pyray as rl
# Unit pentagram vertices, spike up, alternating outer/inner radius, clockwise
# from the top. Inner radius 0.5 keeps the classic star proportions.
_STAR_RING: list[tuple[float, float]] = []
for _k in range(10):
_theta = math.radians(-90.0 + _k * 36.0)
_r = 1.0 if _k % 2 == 0 else 0.5
_STAR_RING.append((_r * math.cos(_theta), _r * math.sin(_theta)))
def draw_star(center_x: float, center_y: float, radius: float, is_filled: bool, color: rl.Color) -> None:
pts = [rl.Vector2(center_x + ux * radius, center_y + uy * radius) for ux, uy in _STAR_RING]
if is_filled:
inner = [pts[k] for k in range(1, 10, 2)]
# five spikes, each flanked by its two inner neighbours...
for i in range(5):
rl.draw_triangle(inner[i - 1], pts[2 * i], inner[i], color)
# ...plus the inner pentagon, fanned from one vertex
for i in range(1, 4):
rl.draw_triangle(inner[0], inner[i], inner[i + 1], color)
for k in range(10):
rl.draw_line_ex(pts[k], pts[(k + 1) % 10], 2, color)

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
import os
import cffi
from dataclasses import dataclass
from typing import Any
from iqpilot.common.swaglog import cloudlog
# EGL constants
EGL_LINUX_DMA_BUF_EXT = 0x3270
EGL_WIDTH = 0x3057
EGL_HEIGHT = 0x3056
EGL_LINUX_DRM_FOURCC_EXT = 0x3271
EGL_DMA_BUF_PLANE0_FD_EXT = 0x3272
EGL_DMA_BUF_PLANE0_OFFSET_EXT = 0x3273
EGL_DMA_BUF_PLANE0_PITCH_EXT = 0x3274
EGL_DMA_BUF_PLANE1_FD_EXT = 0x3275
EGL_DMA_BUF_PLANE1_OFFSET_EXT = 0x3276
EGL_DMA_BUF_PLANE1_PITCH_EXT = 0x3277
EGL_NONE = 0x3038
GL_TEXTURE0 = 0x84C0
GL_TEXTURE_EXTERNAL_OES = 0x8D65
# DRM Format for NV12
DRM_FORMAT_NV12 = 842094158
@dataclass
class EGLImage:
"""Container for EGL image and associated resources"""
egl_image: Any
fd: int
@dataclass
class EGLState:
"""Container for all EGL-related state"""
initialized: bool = False
ffi: Any = None
egl_lib: Any = None
gles_lib: Any = None
# EGL display connection - shared across all users
display: Any = None
# Constants
NO_CONTEXT: Any = None
NO_DISPLAY: Any = None
NO_IMAGE_KHR: Any = None
# Function pointers
get_current_display: Any = None
create_image_khr: Any = None
destroy_image_khr: Any = None
image_target_texture: Any = None
get_error: Any = None
swap_interval: Any = None
bind_texture: Any = None
active_texture: Any = None
flush: Any = None
finish: Any = None
# Create a single instance of the state
_egl = EGLState()
def init_egl() -> bool:
"""Initialize EGL and load necessary functions"""
global _egl
# Don't re-initialize if already done
if _egl.initialized:
return True
try:
_egl.ffi = cffi.FFI()
_egl.ffi.cdef("""
typedef int EGLint;
typedef unsigned int EGLBoolean;
typedef unsigned int EGLenum;
typedef unsigned int GLenum;
typedef void *EGLContext;
typedef void *EGLDisplay;
typedef void *EGLClientBuffer;
typedef void *EGLImageKHR;
typedef void *GLeglImageOES;
EGLDisplay eglGetCurrentDisplay(void);
EGLint eglGetError(void);
EGLImageKHR eglCreateImageKHR(EGLDisplay dpy, EGLContext ctx,
EGLenum target, EGLClientBuffer buffer,
const EGLint *attrib_list);
EGLBoolean eglDestroyImageKHR(EGLDisplay dpy, EGLImageKHR image);
EGLBoolean eglSwapInterval(EGLDisplay dpy, EGLint interval);
void glEGLImageTargetTexture2DOES(GLenum target, GLeglImageOES image);
void glBindTexture(GLenum target, unsigned int texture);
void glActiveTexture(GLenum texture);
void glFlush(void);
void glFinish(void);
""")
# Load libraries
_egl.egl_lib = _egl.ffi.dlopen("libEGL.so")
_egl.gles_lib = _egl.ffi.dlopen("libGLESv2.so")
# Cast NULL pointers
_egl.NO_CONTEXT = _egl.ffi.cast("void *", 0)
_egl.NO_DISPLAY = _egl.ffi.cast("void *", 0)
_egl.NO_IMAGE_KHR = _egl.ffi.cast("void *", 0)
# Bind functions
_egl.get_current_display = _egl.egl_lib.eglGetCurrentDisplay
_egl.create_image_khr = _egl.egl_lib.eglCreateImageKHR
_egl.destroy_image_khr = _egl.egl_lib.eglDestroyImageKHR
_egl.swap_interval = _egl.egl_lib.eglSwapInterval
_egl.image_target_texture = _egl.gles_lib.glEGLImageTargetTexture2DOES
_egl.get_error = _egl.egl_lib.eglGetError
_egl.bind_texture = _egl.gles_lib.glBindTexture
_egl.active_texture = _egl.gles_lib.glActiveTexture
_egl.flush = _egl.gles_lib.glFlush
_egl.finish = _egl.gles_lib.glFinish
# Initialize EGL display once here
_egl.display = _egl.get_current_display()
if _egl.display == _egl.NO_DISPLAY:
raise RuntimeError("Failed to get EGL display")
_egl.initialized = True
return True
except Exception as e:
cloudlog.exception(f"EGL initialization failed: {e}")
_egl.initialized = False
return False
def create_egl_image(width: int, height: int, stride: int, fd: int, uv_offset: int) -> EGLImage | None:
assert _egl.initialized, "EGL not initialized"
try:
# Duplicate fd since EGL needs it
dup_fd = os.dup(fd)
except OSError as e:
cloudlog.exception(f"Failed to duplicate frame fd when creating EGL image: {e}")
return None
# Create image attributes for EGL
img_attrs = [
EGL_WIDTH, width,
EGL_HEIGHT, height,
EGL_LINUX_DRM_FOURCC_EXT, DRM_FORMAT_NV12,
EGL_DMA_BUF_PLANE0_FD_EXT, dup_fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0,
EGL_DMA_BUF_PLANE0_PITCH_EXT, stride,
EGL_DMA_BUF_PLANE1_FD_EXT, dup_fd,
EGL_DMA_BUF_PLANE1_OFFSET_EXT, uv_offset,
EGL_DMA_BUF_PLANE1_PITCH_EXT, stride,
EGL_NONE
]
attr_array = _egl.ffi.new("int[]", img_attrs)
egl_image = _egl.create_image_khr(_egl.display, _egl.NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, _egl.ffi.NULL, attr_array)
if egl_image == _egl.NO_IMAGE_KHR:
cloudlog.error(f"Failed to create EGL image: {_egl.get_error()}")
os.close(dup_fd)
return None
return EGLImage(egl_image=egl_image, fd=dup_fd)
def destroy_egl_image(egl_image: EGLImage) -> None:
assert _egl.initialized, "EGL not initialized"
_egl.destroy_image_khr(_egl.display, egl_image.egl_image)
# Close the duplicated fd we created in create_egl_image()
# We need to handle OSError since the fd might already be closed
try:
os.close(egl_image.fd)
except OSError:
pass
def bind_egl_image_to_texture(texture_id: int, egl_image: EGLImage) -> None:
assert _egl.initialized, "EGL not initialized"
_egl.active_texture(GL_TEXTURE0)
_egl.bind_texture(GL_TEXTURE_EXTERNAL_OES, texture_id)
_egl.image_target_texture(GL_TEXTURE_EXTERNAL_OES, egl_image.egl_image)
_egl.flush()
def set_swap_interval(interval: int) -> bool:
if not _egl.initialized and not init_egl():
return False
return bool(_egl.swap_interval(_egl.display, max(0, int(interval))))
def finish_gl() -> bool:
if not _egl.initialized and not init_egl():
return False
_egl.finish()
return True

View File

@@ -0,0 +1,67 @@
import io
import re
from PIL import Image, ImageDraw, ImageFont
import pyray as rl
from iqpilot.system.ui.lib.application import FONT_DIR
_emoji_font: ImageFont.FreeTypeFont | None = None
_cache: dict[str, rl.Texture] = {}
EMOJI_REGEX = re.compile(
"""[\U0001F600-\U0001F64F
\U0001F300-\U0001F5FF
\U0001F680-\U0001F6FF
\U0001F1E0-\U0001F1FF
\U00002700-\U000027BF
\U0001F900-\U0001F9FF
\U00002600-\U000026FF
\U00002300-\U000023FF
\U00002B00-\U00002BFF
\U0001FA70-\U0001FAFF
\U0001F700-\U0001F77F
\u2640-\u2642
\u2600-\u2B55
\u200d
\u23cf
\u23e9
\u231a
\ufe0f
\u3030
]+""".replace("\n", ""),
flags=re.UNICODE
)
_emoji_font_loaded = False
def _load_emoji_font() -> ImageFont.FreeTypeFont | None:
global _emoji_font, _emoji_font_loaded
if not _emoji_font_loaded:
_emoji_font_loaded = True
try:
# FONT_DIR is an importlib.resources path. Inside the setup zipapp it points into the archive,
# so str() yields a path through the .zip that PIL can't open ("cannot open resource"). Read
# the bytes and hand PIL a file object so it works both on disk and inside the zipapp.
_emoji_font = ImageFont.truetype(io.BytesIO(FONT_DIR.joinpath("NotoColorEmoji.ttf").read_bytes()), 109)
except Exception:
_emoji_font = None # never crash the whole UI over an emoji glyph
return _emoji_font
def find_emoji(text):
return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)]
def emoji_tex(emoji):
if emoji not in _cache:
font = _load_emoji_font()
if font is None:
return None
img = Image.new("RGBA", (128, 128), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((0, 0), emoji, font=font, embedded_color=True)
with io.BytesIO() as buffer:
img.save(buffer, format="PNG")
l = buffer.tell()
buffer.seek(0)
_cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l))
return _cache.get(emoji)

View File

@@ -0,0 +1,98 @@
from collections.abc import Callable
from importlib.resources import files
import os
import json
import gettext
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.swaglog import cloudlog
try:
from iqpilot.common.params import Params
except ImportError:
Params = None
SYSTEM_UI_DIR = os.path.join(BASEDIR, "iqpilot", "system", "ui")
UI_DIR = files("iqpilot.selfdrive.ui")
TRANSLATIONS_DIR = UI_DIR.joinpath("translations")
LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json")
UNIFONT_LANGUAGES = [
"ar",
"th",
"zh-CHT",
"zh-CHS",
"ko",
"ja",
]
class Multilang:
def __init__(self):
self._params = Params() if Params is not None else None
self._language: str = "en"
self.languages = {}
self.codes = {}
self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations()
self._change_callbacks: list[Callable[[], None]] = []
self._load_languages()
@property
def language(self) -> str:
return self._language
def requires_unifont(self) -> bool:
"""Certain languages require unifont to render their glyphs."""
return self._language in UNIFONT_LANGUAGES
def setup(self):
try:
with TRANSLATIONS_DIR.joinpath(f'app_{self._language}.mo').open('rb') as fh:
translation = gettext.GNUTranslations(fh)
translation.install()
self._translation = translation
cloudlog.debug(f"Loaded translations for language: {self._language}")
except FileNotFoundError:
cloudlog.error(f"No translation file found for language: {self._language}, using default.")
gettext.install('app')
self._translation = gettext.NullTranslations()
def add_change_callback(self, callback: Callable[[], None]) -> None:
self._change_callbacks.append(callback)
def change_language(self, language_code: str) -> None:
# Reinstall gettext with the selected language
self._params.put("LanguageSetting", language_code)
self._language = language_code
self.setup()
for callback in self._change_callbacks:
try:
callback()
except Exception:
cloudlog.exception("multilang: language change callback failed")
def tr(self, text: str) -> str:
return self._translation.gettext(text)
def trn(self, singular: str, plural: str, n: int) -> str:
return self._translation.ngettext(singular, plural, n)
def _load_languages(self):
with LANGUAGES_FILE.open(encoding='utf-8') as f:
self.languages = json.load(f)
self.codes = {v: k for k, v in self.languages.items()}
if self._params is not None:
lang = str(self._params.get("LanguageSetting")).removeprefix("main_")
if lang in self.codes:
self._language = lang
multilang = Multilang()
multilang.setup()
tr, trn = multilang.tr, multilang.trn
# no-op marker for static strings translated later
def tr_noop(s: str) -> str:
return s

View File

@@ -0,0 +1,47 @@
from enum import IntEnum
# NetworkManager device states
class NMDeviceState(IntEnum):
UNKNOWN = 0
DISCONNECTED = 30
PREPARE = 40
STATE_CONFIG = 50
NEED_AUTH = 60
IP_CONFIG = 70
ACTIVATED = 100
DEACTIVATING = 110
# NetworkManager constants
NM = "org.freedesktop.NetworkManager"
NM_PATH = '/org/freedesktop/NetworkManager'
NM_IFACE = 'org.freedesktop.NetworkManager'
NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint'
NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings'
NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings'
NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection'
NM_ACTIVE_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Connection.Active'
NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless'
NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'
NM_DEVICE_IFACE = 'org.freedesktop.NetworkManager.Device'
NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config'
NM_DEVICE_TYPE_WIFI = 2
NM_DEVICE_TYPE_MODEM = 8
NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags
NM_802_11_AP_FLAGS_NONE = 0x0
NM_802_11_AP_FLAGS_PRIVACY = 0x1
NM_802_11_AP_FLAGS_WPS = 0x2
# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags
NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001
NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002
NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010
NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020
NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100
NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200
NM_802_11_AP_SEC_KEY_MGMT_SAE = 0x00000400 # WPA3-Personal (SAE)

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
IQ.OS compatibility check + in-place AGNOS update for the setup flow.
A chosen IQ.Pilot channel may target a newer IQ.OS than the device is running
(its cloned tree pins the required version in launch_env.sh AGNOS_VERSION). When
that differs from the running /VERSION, the setup flow flashes the target IQ.OS
via comma's own agnos.py BEFORE writing continue.sh, so the single reboot lands
on a compatible OS. The risky flashing is delegated entirely to agnos.py; this
module only reads versions, picks the right manifest, and streams coarse
progress.
"""
import json
import os
import re
import subprocess
import threading
from typing import Callable
VERSION_PATH = "/VERSION"
def current_os_version() -> str:
try:
with open(VERSION_PATH) as f:
return f.read().strip()
except Exception:
return ""
def required_agnos_version(install_path: str) -> str:
"""Read the target OS version the cloned fork pins in launch_env.sh."""
path = os.path.join(install_path, "launch_env.sh")
try:
with open(path) as f:
for line in f:
m = re.search(r'AGNOS_VERSION\s*=\s*"([^"]+)"', line)
if m:
return m.group(1).strip()
except Exception:
pass
return ""
def _hardware_dir(install_path: str) -> str:
nested = os.path.join(install_path, "iqpilot", "system", "hardware", "tici")
if os.path.isdir(nested):
return nested
return os.path.join(install_path, "system", "hardware", "tici")
def agnos_manifest_path(install_path: str, device_type: str) -> str:
# comma 3 (tici) uses a different AGNOS manifest than comma 3x (tizi) / comma 4 (mici).
fname = "agnos_tici_15_1.json" if device_type == "tici" else "agnos.json"
return os.path.join(_hardware_dir(install_path), fname)
def os_update_needed(install_path: str) -> tuple[bool, str, str]:
"""Returns (needed, current, required)."""
current = current_os_version()
required = required_agnos_version(install_path)
needed = bool(required and current and required != current)
return needed, current, required
ProgressCb = Callable[[int, str], None]
def run_agnos_update(install_path: str, device_type: str, progress_cb: ProgressCb) -> bool:
"""Flash + swap to the target IQ.OS. Streams coarse partition-level progress
via progress_cb(percent, note). Returns True on success. The device must be
rebooted by the caller afterward for the new slot to take effect."""
manifest = agnos_manifest_path(install_path, device_type)
agnos_py = os.path.join(_hardware_dir(install_path), "agnos.py")
if not os.path.isfile(manifest) or not os.path.isfile(agnos_py):
progress_cb(0, "manifest_missing")
return False
try:
total_partitions = max(1, len(json.load(open(manifest))))
except Exception:
total_partitions = 1
progress_cb(1, "starting")
try:
proc = subprocess.Popen(
["python3", agnos_py, "--swap", manifest],
cwd=install_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env={**os.environ, "PYTHONPATH": install_path},
)
except Exception:
progress_cb(0, "launch_failed")
return False
completed = 0
swapping = False
assert proc.stdout is not None
for line in proc.stdout:
line = line.strip()
if "Downloading and writing" in line or "Already flashed" in line:
completed += 1
pct = min(94, int((completed / total_partitions) * 90) + 2)
progress_cb(pct, "flashing")
elif "Swapping to slot" in line or "AGNOS ready" in line:
swapping = True
progress_cb(96, "swapping")
proc.wait()
if proc.returncode == 0:
progress_cb(100, "done")
return True
progress_cb(0, "failed" if not swapping else "swap_failed")
return False
class OsUpdateCoordinator:
"""Bridges the setup UI's install thread and the BLE confirmation from the app.
The install thread posts a required-update, waits for the phone's confirm, then
runs the flash. On-screen setup can confirm locally too."""
def __init__(self):
self.confirmed = threading.Event()
self.needed = False
self.current = ""
self.required = ""
def request(self, current: str, required: str) -> None:
self.needed = True
self.current = current
self.required = required
self.confirmed.clear()
def confirm(self) -> None:
self.confirmed.set()
def wait_for_confirm(self, timeout: float) -> bool:
return self.confirmed.wait(timeout=timeout)

View File

@@ -0,0 +1,34 @@
"""Compatibility wrappers for raylib API differences between wheel generations.
PC dev environments install the pypi raylib 5.5 wheel, while devices ship a wheel
built from comma's raylib fork, which carries newer upstream API changes:
- DrawCircleGradient takes a Vector2 center instead of int x/y
- DrawRectangleGradientEx swapped its two right-corner color parameters
(old: topLeft, bottomLeft, topRight, bottomRight
new: topLeft, bottomLeft, bottomRight, topRight)
- LoadFontData grew an int *glyphCount out-param and returns a compacted array
(handled where it's used, in selfdrive/assets/fonts/process.py)
The wheel is a single snapshot, so one signature identifies the generation for all
of them. Detect it from the C function type via cffi rather than hardcoding either
variant — the pyray wrappers are *args shims and can't be introspected directly.
"""
import pyray as rl
import raylib as _raylib
_NEW_API = _raylib.ffi.typeof(_raylib.rl.DrawCircleGradient).args[0].cname != "int"
def draw_circle_gradient(center_x: float, center_y: float, radius: float, inner, outer) -> None:
if _NEW_API:
rl.draw_circle_gradient(rl.Vector2(center_x, center_y), radius, inner, outer)
else:
rl.draw_circle_gradient(int(center_x), int(center_y), radius, inner, outer)
def draw_rectangle_gradient_ex(rec, top_left, bottom_left, top_right, bottom_right) -> None:
if _NEW_API:
rl.draw_rectangle_gradient_ex(rec, top_left, bottom_left, bottom_right, top_right)
else:
rl.draw_rectangle_gradient_ex(rec, top_left, bottom_left, top_right, bottom_right)

View File

@@ -0,0 +1,293 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from __future__ import annotations
import math
import os
import queue
import shutil
import subprocess
import threading
import time
import pyray as rl
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.system.hardware.hw import Paths
PARAM_KEY = "ScreenRecording"
PARAM_POLL_INTERVAL = 0.5 # seconds between param checks
CAPTURE_FPS = int(os.getenv("SCREEN_RECORD_FPS", "15"))
MAX_DURATION_S = int(os.getenv("SCREEN_RECORD_MAX_S", str(10 * 60))) # auto-stop safety
MIN_FREE_DISK_BYTES = 2 * 1024**3 # refuse to record with < 2GB free
QUEUE_MAX_FRAMES = 30 # ~2s of frames before we start dropping
MIN_VALID_OUTPUT_BYTES = 8 * 1024
CRF = os.getenv("SCREEN_RECORD_CRF", "28")
MAX_BITRATE = os.getenv("SCREEN_RECORD_MAXRATE", "2M")
# capture at half res above this width (BIG UI 2160 -> 1080), native below (mici 536)
DOWNSCALE_THRESHOLD = 1200
# Skip capture while the UI frame time is above this multiple of target: the readback
# (blocking glReadPixels) and extra present-blit stall the render thread, so back off and
# let the UI recover rather than dragging its framerate down. The recorder self-throttles.
BACKOFF_FRAME_TIME_RATIO = float(os.getenv("SCREEN_RECORD_BACKOFF", "1.35"))
class ScreenRecorder:
def __init__(self, width: int, height: int, target_fps: int):
self._width = width
self._height = height
scale = 2 if width > DOWNSCALE_THRESHOLD else 1
# yuv420 needs even dimensions
self._rec_width = (width // scale) & ~1
self._rec_height = (height // scale) & ~1
self._target_fps = max(1, target_fps)
self._capture_fps = max(1, min(CAPTURE_FPS, target_fps))
self._frame_interval = max(1, round(target_fps / self._capture_fps))
self._target_frame_time = 1.0 / self._target_fps
self._params = Params()
self._active = False
self._owns_rt = False
self._rt: rl.RenderTexture | None = None
self._small_rt: rl.RenderTexture | None = None
self._proc: subprocess.Popen | None = None
self._queue: queue.Queue[bytes | None] | None = None
self._writer: threading.Thread | None = None
self._finalizers: list[threading.Thread] = []
self._frame_idx = 0
self._dropped = 0
self._backoff_skips = 0
self._start_time = 0.0
self._last_poll = 0.0
self._out_path = ""
# after a self-initiated stop, don't restart until the param is observed False
self._await_param_clear = False
# adaptive backoff: EMA of the render-loop frame time
self._last_frame_t = 0.0
self._frame_time_ema = 0.0
self._capture_this_frame = False
@property
def active(self) -> bool:
return self._active
@property
def render_texture(self) -> rl.RenderTexture | None:
return self._rt
def begin_frame(self) -> bool:
self._capture_this_frame = False
if not self._active:
return False
now = time.monotonic()
if self._last_frame_t:
dt = now - self._last_frame_t
self._frame_time_ema = dt if self._frame_time_ema == 0.0 else 0.85 * self._frame_time_ema + 0.15 * dt
self._last_frame_t = now
self._frame_idx += 1
if self._frame_idx % self._frame_interval != 0:
return False
if self._frame_time_ema > self._target_frame_time * BACKOFF_FRAME_TIME_RATIO:
self._backoff_skips += 1
if self._backoff_skips % 200 == 1:
cloudlog.warning(f"screen_recorder: UI busy, backing off capture ({self._backoff_skips} skips)")
return False
self._capture_this_frame = True
return True
def update(self, app_render_texture: rl.RenderTexture | None) -> None:
"""Called every frame from the render thread. Handles start/stop and safety limits."""
now = time.monotonic()
if self._active:
# encoder died (e.g. broken pipe) or hit the duration cap
if self._proc is not None and self._proc.poll() is not None:
cloudlog.error(f"screen_recorder: encoder exited unexpectedly rc={self._proc.returncode}")
self._stop(clear_param=True)
elif now - self._start_time > MAX_DURATION_S:
cloudlog.warning("screen_recorder: max duration reached, auto-stopping")
self._stop(clear_param=True)
if now - self._last_poll < PARAM_POLL_INTERVAL:
return
self._last_poll = now
want = self._params.get_bool(PARAM_KEY)
if self._await_param_clear:
if want:
return
self._await_param_clear = False
if want and not self._active:
self._start(app_render_texture)
elif not want and self._active:
self._stop(clear_param=False)
def _start(self, app_render_texture: rl.RenderTexture | None) -> None:
root = Paths.screen_recordings_root()
try:
os.makedirs(root, exist_ok=True)
if shutil.disk_usage(root).free < MIN_FREE_DISK_BYTES:
cloudlog.warning("screen_recorder: not enough free disk space, refusing to record")
self._params.put_bool_nonblocking(PARAM_KEY, False)
return
except OSError as e:
cloudlog.exception(f"screen_recorder: cannot prepare output dir: {e}")
self._params.put_bool_nonblocking(PARAM_KEY, False)
return
if app_render_texture is not None:
self._rt = app_render_texture
self._owns_rt = False
else:
self._rt = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._rt.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._owns_rt = True
self._small_rt = rl.load_render_texture(self._rec_width, self._rec_height)
self._out_path = os.path.join(root, time.strftime("screen_recording_%Y-%m-%d_%H-%M-%S.mp4"))
args = [
'ffmpeg', '-v', 'error', '-nostats',
'-f', 'rawvideo', '-pix_fmt', 'rgba',
'-s', f'{self._rec_width}x{self._rec_height}',
'-r', str(self._capture_fps), '-i', 'pipe:0',
'-vf', 'vflip,format=yuv420p',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-crf', CRF, '-maxrate', MAX_BITRATE, '-bufsize', '4M',
'-g', str(self._capture_fps * 2),
'-threads', '2',
# fragmented mp4: file stays playable even if we die before a clean stop
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
'-y', '-f', 'mp4', self._out_path,
]
try:
self._proc = subprocess.Popen(args, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=lambda: os.nice(15))
except OSError as e:
cloudlog.exception(f"screen_recorder: failed to start ffmpeg: {e}")
self._release_textures()
self._params.put_bool_nonblocking(PARAM_KEY, False)
return
self._queue = queue.Queue(maxsize=QUEUE_MAX_FRAMES)
self._writer = threading.Thread(target=self._writer_thread, args=(self._proc, self._queue), daemon=True)
self._writer.start()
self._frame_idx = 0
self._dropped = 0
self._backoff_skips = 0
self._last_frame_t = 0.0
self._frame_time_ema = 0.0
self._start_time = time.monotonic()
self._active = True
cloudlog.event("screen_recorder: started", path=self._out_path,
size=f"{self._rec_width}x{self._rec_height}", fps=self._capture_fps)
def _stop(self, clear_param: bool) -> None:
self._active = False
proc, q, writer = self._proc, self._queue, self._writer
self._proc, self._queue, self._writer = None, None, None
self._release_textures()
if clear_param:
self._params.put_bool_nonblocking(PARAM_KEY, False)
self._await_param_clear = True
# finalize off the render thread; ffmpeg needs a clean stdin close to flush
out_path = self._out_path
def _finalize():
try:
if q is not None:
q.put(None)
if writer is not None:
writer.join(timeout=10)
if proc is not None:
if proc.stdin is not None:
try:
proc.stdin.close()
except OSError:
pass
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=5)
try:
if out_path and os.path.getsize(out_path) < MIN_VALID_OUTPUT_BYTES:
os.remove(out_path)
cloudlog.warning(f"screen_recorder: discarded truncated output {out_path}")
except OSError:
pass
except Exception:
cloudlog.exception("screen_recorder: finalize failed")
t = threading.Thread(target=_finalize, daemon=True)
t.start()
self._finalizers = [f for f in self._finalizers if f.is_alive()] + [t]
cloudlog.event("screen_recorder: stopped", path=self._out_path, dropped=self._dropped)
def _release_textures(self) -> None:
# render thread only
if self._small_rt is not None:
rl.unload_render_texture(self._small_rt)
self._small_rt = None
if self._rt is not None and self._owns_rt:
rl.unload_render_texture(self._rt)
self._rt = None
self._owns_rt = False
def capture(self, frame_rt: rl.RenderTexture) -> None:
if not self._capture_this_frame or self._small_rt is None or self._queue is None:
return
if self._queue.full():
self._dropped += 1
if self._dropped % 100 == 1:
cloudlog.warning(f"screen_recorder: encoder falling behind, dropped {self._dropped} frames")
return
src = rl.Rectangle(0, 0, float(frame_rt.texture.width), -float(frame_rt.texture.height))
dst = rl.Rectangle(0, 0, float(self._rec_width), float(self._rec_height))
rl.begin_texture_mode(self._small_rt)
rl.draw_texture_pro(frame_rt.texture, src, dst, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_texture_mode()
image = rl.load_image_from_texture(self._small_rt.texture)
try:
data = bytes(rl.ffi.buffer(image.data, self._rec_width * self._rec_height * 4))
finally:
rl.unload_image(image)
try:
self._queue.put_nowait(data)
except queue.Full:
self._dropped += 1
def draw_indicator(self, width: int) -> None:
"""Pulsing red REC dot, drawn into the frame so it shows on screen and in the video."""
big = width > DOWNSCALE_THRESHOLD
radius = 14 if big else 7
margin = (28 if big else 14) + radius
alpha = int(155 + 100 * math.sin(time.monotonic() * 4.0))
rl.draw_circle(width - margin, margin, float(radius), rl.Color(255, 60, 60, alpha))
@staticmethod
def _writer_thread(proc: subprocess.Popen, q: queue.Queue[bytes | None]) -> None:
while True:
data = q.get()
if data is None:
break
try:
proc.stdin.write(data)
except (BrokenPipeError, OSError):
break
def close(self) -> None:
if self._active:
self._stop(clear_param=False)
for t in self._finalizers:
t.join(timeout=15)

View File

@@ -0,0 +1,136 @@
import math
import pyray as rl
from enum import IntEnum
from iqpilot.system.ui.lib.application import gui_app, MouseEvent
from iqpilot.common.filter_simple import FirstOrderFilter
# Scroll constants for smooth scrolling behavior
MOUSE_WHEEL_SCROLL_SPEED = 50
BOUNCE_RETURN_RATE = 5 # ~0.92 at 60fps
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
DRAG_THRESHOLD = 12 # pixels of movement to consider it a drag, not a click
DEBUG = False
class ScrollState(IntEnum):
IDLE = 0 # Not dragging, content may be bouncing or scrolling with inertia
DRAGGING_CONTENT = 1 # User is actively dragging the content
class GuiScrollPanel:
def __init__(self):
self._scroll_state: ScrollState = ScrollState.IDLE
self._last_mouse_y: float = 0.0
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection
self._offset_filter_y = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._velocity_filter_y = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
self._last_drag_time: float = 0.0
def update(self, bounds: rl.Rectangle, content: rl.Rectangle) -> float:
for mouse_event in gui_app.mouse_events:
if mouse_event.slot == 0:
self._handle_mouse_event(mouse_event, bounds, content)
self._update_state(bounds, content)
return float(self._offset_filter_y.x)
def _update_state(self, bounds: rl.Rectangle, content: rl.Rectangle):
if DEBUG:
rl.draw_rectangle_lines(0, 0, abs(int(self._velocity_filter_y.x)), 10, rl.RED)
# wheel scrolls this panel only while the cursor sits inside its bounds
wheel = rl.get_mouse_wheel_move()
if wheel and rl.check_collision_point_rec(rl.get_mouse_position(), bounds):
self._offset_filter_y.x += wheel * MOUSE_WHEEL_SCROLL_SPEED
max_scroll_distance = max(0, content.height - bounds.height)
if self._scroll_state == ScrollState.IDLE:
above_bounds, below_bounds = self._check_bounds(bounds, content)
# Decay velocity when idle
if abs(self._velocity_filter_y.x) > MIN_VELOCITY:
# Faster decay if bouncing back from out of bounds
friction = math.exp(-BOUNCE_RETURN_RATE * 1 / gui_app.target_fps)
self._velocity_filter_y.x *= friction ** 2 if (above_bounds or below_bounds) else friction
else:
self._velocity_filter_y.x = 0.0
if above_bounds or below_bounds:
if above_bounds:
self._offset_filter_y.update(0)
else:
self._offset_filter_y.update(-max_scroll_distance)
self._offset_filter_y.x += self._velocity_filter_y.x / gui_app.target_fps
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
# Mouse not moving, decay velocity
if not len(gui_app.mouse_events):
self._velocity_filter_y.update(0.0)
# Settle to exact bounds
if abs(self._offset_filter_y.x) < 1e-2:
self._offset_filter_y.x = 0.0
elif abs(self._offset_filter_y.x + max_scroll_distance) < 1e-2:
self._offset_filter_y.x = -max_scroll_distance
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, content: rl.Rectangle):
if self._scroll_state == ScrollState.IDLE:
if rl.check_collision_point_rec(mouse_event.pos, bounds):
if mouse_event.left_pressed:
self._start_mouse_y = mouse_event.pos.y
# Interrupt scrolling with new drag
# TODO: stop scrolling with any tap, need to fix is_touch_valid
if abs(self._velocity_filter_y.x) > MIN_VELOCITY_FOR_CLICKING:
self._scroll_state = ScrollState.DRAGGING_CONTENT
# Start velocity at initial measurement for more immediate response
self._velocity_filter_y.initialized = False
if mouse_event.left_down:
if abs(mouse_event.pos.y - self._start_mouse_y) > DRAG_THRESHOLD:
self._scroll_state = ScrollState.DRAGGING_CONTENT
# Start velocity at initial measurement for more immediate response
self._velocity_filter_y.initialized = False
elif self._scroll_state == ScrollState.DRAGGING_CONTENT:
if mouse_event.left_released:
self._scroll_state = ScrollState.IDLE
else:
delta_y = mouse_event.pos.y - self._last_mouse_y
above_bounds, below_bounds = self._check_bounds(bounds, content)
# Rubber banding effect when out of bands
if above_bounds or below_bounds:
delta_y /= 3
self._offset_filter_y.x += delta_y
# Track velocity for inertia
dt = mouse_event.t - self._last_drag_time
if dt > 0:
drag_velocity = delta_y / dt
self._velocity_filter_y.update(drag_velocity)
# TODO: just store last mouse event!
self._last_drag_time = mouse_event.t
self._last_mouse_y = mouse_event.pos.y
def _check_bounds(self, bounds: rl.Rectangle, content: rl.Rectangle) -> tuple[bool, bool]:
max_scroll_distance = max(0, content.height - bounds.height)
above_bounds = self._offset_filter_y.x > 0
below_bounds = self._offset_filter_y.x < -max_scroll_distance
return above_bounds, below_bounds
def is_touch_valid(self):
return self._scroll_state == ScrollState.IDLE and abs(self._velocity_filter_y.x) < MIN_VELOCITY_FOR_CLICKING
def set_offset(self, position: float) -> None:
self._offset_filter_y.x = position
self._velocity_filter_y.x = 0.0
self._scroll_state = ScrollState.IDLE
@property
def offset(self) -> float:
return float(self._offset_filter_y.x)

View File

@@ -0,0 +1,230 @@
import os
import math
import pyray as rl
from collections.abc import Callable
from enum import Enum
from typing import cast
from iqpilot.system.ui.lib.application import gui_app, MouseEvent
from iqpilot.system.hardware import TICI
from collections import deque
MIN_VELOCITY = 10 # 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"
def weighted_velocity(buffer: deque[float]) -> float:
if len(buffer) >= 3:
return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05
if len(buffer) == 2:
return buffer[-2] * 0.7 + buffer[-1] * 0.3
if len(buffer) == 1:
return buffer[-1]
return 0.0
# 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 _get_offset_bounds(self, bounds_size: float, content_size: float) -> tuple[float, float]:
"""Returns (max_offset, min_offset) for the given bounds and content size."""
return 0.0, min(0.0, bounds_size - content_size)
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:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
# simple exponential return if out of bounds
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if out_of_bounds and self._handle_out_of_bounds:
target = max_offset if self.get_offset() > max_offset else min_offset
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._velocity = 0.0
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:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
# simple exponential return if out of bounds
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
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:
high_decel = False
if len(self._velocity_buffer) > 2:
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
self._velocity = weighted_velocity(self._velocity_buffer)
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
delta_t = max((mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t), 1e-6)
self._velocity = delta_x / delta_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) -> float:
return 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)

View File

@@ -0,0 +1,744 @@
#!/usr/bin/env python3
"""
Konn3kt BLE Setup transport (Phase A) — runs inside the setup zipapp, before any
IQ.Pilot install. Advertises the device on the setup screen so the konn3kt app
can drive Wi-Fi + install over Bluetooth. Self-contained (the compiled
ble-transportd bundle lives on the wiped /data and is unavailable here); uses the
AGNOS system python's gi/BlueZ D-Bus, mirroring the settings transport's GATT +
fragmentation + auth so the app can share client code.
See konn3kt_private/docs/konn3kt_ble_setup_protocol.md for the wire contract.
"""
import hashlib
import hmac
import json
import math
import os
import re
import secrets
import struct
import subprocess
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable
def _import_gi():
# In setup mode /data is wiped, so the installed system's gi symlink
# (/data/openpilot/gi) is gone and the venv has no gi of its own. The real gi
# dist-package + GI typelibs live in the rootfs and survive reset — make them
# importable before falling over. Works unchanged on an installed device too.
import sys
try:
import gi # noqa: F401
except Exception:
for extra in ("/usr/lib/python3/dist-packages", "/usr/lib/python3.12/dist-packages"):
import os
if os.path.isdir(os.path.join(extra, "gi")) and extra not in sys.path:
sys.path.append(extra)
import gi # noqa: F401
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
return gi, Gio, GLib
try:
gi, Gio, GLib = _import_gi()
_GI_AVAILABLE = True
except Exception:
_GI_AVAILABLE = False
BLUEZ_SERVICE = "org.bluez"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties"
GATT_MANAGER_IFACE = "org.bluez.GattManager1"
LE_ADV_MANAGER_IFACE = "org.bluez.LEAdvertisingManager1"
GATT_SERVICE_IFACE = "org.bluez.GattService1"
GATT_CHRC_IFACE = "org.bluez.GattCharacteristic1"
LE_ADV_IFACE = "org.bluez.LEAdvertisement1"
DEVICE_IFACE = "org.bluez.Device1"
ADAPTER_IFACE = "org.bluez.Adapter1"
SETUP_SERVICE_UUID = "73f2c700-5e40-4d0d-8b7f-fde61f729100"
SETUP_CONTROL_CHAR_UUID = "73f2c701-5e40-4d0d-8b7f-fde61f729100"
SETUP_REQUEST_CHAR_UUID = "73f2c702-5e40-4d0d-8b7f-fde61f729100"
SETUP_RESPONSE_CHAR_UUID = "73f2c703-5e40-4d0d-8b7f-fde61f729100"
# 16-bit ServiceData UUID for the advertisement — a 128-bit service UUID PLUS
# 128-bit service data overflows the 31-byte legacy adv budget, so the setupId
# rides in a compact 16-bit ServiceData AD instead (the full 128-bit service is
# still the GATT service, discovered after connect). BlueZ expands "fe01" to the
# Bluetooth base UUID; the app reads it as 0000fe01-0000-1000-8000-00805f9b34fb.
SETUP_SERVICE_DATA_UUID = "0000fe01-0000-1000-8000-00805f9b34fb"
PROTOCOL_VERSION = 1
FRAME_HEADER = struct.Struct(">IHH")
MAX_FRAGMENT_PAYLOAD = 180
ADAPTER_WAIT_TIMEOUT_S = 8.0
BLUEZ_REGISTER_TIMEOUT_MS = 15000
SESSION_IDLE_TIMEOUT_S = 120
SEQ_REPLAY_WINDOW = 128
HELLO_MAX_SKEW_MS = 300_000
MAX_AUTH_FAILURES = 5
AUTH_LOCKOUT_S = 30.0
def setup_id_for_serial(serial: str) -> str:
return hashlib.sha256(f"k3setup:{serial}".encode("utf-8")).hexdigest()[:16]
def _derive_setup_bdaddr(serial: str) -> str:
raw = bytearray(hashlib.sha256(f"konn3kt-bdaddr:{serial}".encode("utf-8")).digest()[:6])
raw[0] = (raw[0] | 0x02) & 0xFE
return ":".join(f"{x:02X}" for x in raw)
def _json_safe(value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return value
def _canonical(value: Any) -> bytes:
return json.dumps(_json_safe(value), ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def _safe_json(data: dict[str, Any]) -> bytes:
return _canonical(data)
def _hmac_hex(key: bytes, payload: bytes) -> str:
return hmac.new(key, payload, hashlib.sha256).hexdigest()
class SetupAuthError(Exception):
pass
# ---------------------------------------------------------------------------
# Session / auth (6-digit code, mirrors settings-transport HKDF + replay window)
# ---------------------------------------------------------------------------
@dataclass
class SetupSession:
client_id: str
setup_id: str
client_nonce: str
device_nonce: str
session_id: str
authenticated: bool = False
session_key: bytes = b""
last_seen: float = 0.0
highest_seq: int = 0
seen_seq_mask: int = 0
class SetupSessionManager:
def __init__(self, setup_id: str, code_getter: Callable[[], str]):
self._setup_id = setup_id
self._code_getter = code_getter
self._sessions: dict[str, SetupSession] = {}
self._auth_failures = 0
self._lockout_until = 0.0
self._lock = threading.Lock()
def prune(self) -> None:
now = time.monotonic()
with self._lock:
for cid in [c for c, s in self._sessions.items() if (now - s.last_seen) > SESSION_IDLE_TIMEOUT_S]:
self._sessions.pop(cid, None)
def begin_hello(self, client_id: str, setup_id: str, client_nonce: str, timestamp_ms: int) -> dict[str, Any]:
if setup_id != self._setup_id:
raise SetupAuthError("setup_id_mismatch")
now_ms = int(time.time() * 1000)
# NOTE: deliberately NO timestamp-skew check here. A freshly-reset device has
# no network, so its clock is arbitrarily wrong (often weeks off) — a skew
# check would reject every real setup. The 6-digit on-screen code is the
# actual authorization and the per-session nonce prevents replay, so the
# timestamp is informational only.
session = SetupSession(
client_id=client_id,
setup_id=self._setup_id,
client_nonce=str(client_nonce),
device_nonce=secrets.token_hex(8),
session_id=secrets.token_hex(8),
last_seen=time.monotonic(),
)
with self._lock:
self._sessions[client_id] = session
return {
"type": "helloAck",
"protocolVersion": PROTOCOL_VERSION,
"setupId": self._setup_id,
"deviceNonce": session.device_nonce,
"sessionId": session.session_id,
"codeRequired": True,
"timestampMs": now_ms,
}
def _session_key(self, session: SetupSession, code: str) -> bytes:
salt = f"{session.client_nonce}:{session.device_nonce}:{session.session_id}:{self._setup_id}".encode("utf-8")
prk = hmac.new(salt, f"k3setup:{code}".encode("utf-8"), hashlib.sha256).digest()
return hmac.new(prk, b"konn3kt-ble-setup-v1\x01", hashlib.sha256).digest()
def authenticate(self, client_id: str, session_id: str, timestamp_ms: int, proof_hex: str) -> dict[str, Any]:
if time.monotonic() < self._lockout_until:
raise SetupAuthError("auth_locked_out")
session = self._sessions.get(client_id)
if session is None or session.session_id != session_id:
raise SetupAuthError("unknown_session")
code = str(self._code_getter() or "")
key = self._session_key(session, code)
expected = _hmac_hex(key, _canonical({"role": "client-auth", "sessionId": session_id, "timestampMs": int(timestamp_ms)}))
if not hmac.compare_digest(expected, str(proof_hex or "").strip().lower()):
self._auth_failures += 1
if self._auth_failures >= MAX_AUTH_FAILURES:
self._lockout_until = time.monotonic() + AUTH_LOCKOUT_S
self._auth_failures = 0
raise SetupAuthError("auth_failed")
self._auth_failures = 0
session.authenticated = True
session.session_key = key
session.highest_seq = 0
session.seen_seq_mask = 0
session.last_seen = time.monotonic()
return {
"type": "authOk",
"sessionId": session_id,
"setupId": self._setup_id,
"timestampMs": int(time.time() * 1000),
"proof": _hmac_hex(key, _canonical({"role": "device-auth", "sessionId": session_id, "timestampMs": int(timestamp_ms)})),
}
def validate_request(self, client_id: str, session_id: str) -> SetupSession:
session = self._sessions.get(client_id)
if session is None or not session.authenticated:
raise SetupAuthError("session_not_authenticated")
if session.session_id != session_id:
raise SetupAuthError("session_id_mismatch")
session.last_seen = time.monotonic()
return session
def _check_seq(self, session: SetupSession, seq: int) -> None:
seq = int(seq)
if seq <= 0:
raise SetupAuthError("seq_invalid")
if seq > session.highest_seq:
return
offset = session.highest_seq - seq
if offset >= SEQ_REPLAY_WINDOW or (session.seen_seq_mask >> offset) & 1:
raise SetupAuthError("seq_replayed")
def _consume_seq(self, session: SetupSession, seq: int) -> None:
seq = int(seq)
if seq > session.highest_seq:
shift = seq - session.highest_seq
session.seen_seq_mask = ((session.seen_seq_mask << shift) | 1) & ((1 << SEQ_REPLAY_WINDOW) - 1)
session.highest_seq = seq
else:
session.seen_seq_mask |= 1 << (session.highest_seq - seq)
def validate_signed_request(self, client_id: str, session_id: str, request_id: Any, seq: int, method: str, params: Any, mac_hex: str) -> SetupSession:
session = self.validate_request(client_id, session_id)
if not session.session_key:
raise SetupAuthError("missing_session_key")
self._check_seq(session, seq)
payload = _canonical({"id": request_id, "method": method, "params": params, "seq": int(seq), "sessionId": session_id, "type": "request"})
if not hmac.compare_digest(_hmac_hex(session.session_key, payload), str(mac_hex or "").strip().lower()):
raise SetupAuthError("request_mac_invalid")
self._consume_seq(session, seq)
return session
def build_response(self, session: SetupSession | None, request_id: Any, seq: int | None, *, result: Any = None, error: str | None = None) -> dict[str, Any]:
rtype = "error" if error is not None else "response"
env: dict[str, Any] = {"type": rtype, "id": request_id}
if error is not None:
env["error"] = error
else:
env["result"] = result
if session is not None and session.session_key and seq is not None:
payload = error if error is not None else result
env["seq"] = int(seq)
env["mac"] = _hmac_hex(session.session_key, _canonical({"id": request_id, "payload": payload, "seq": int(seq), "sessionId": session.session_id, "type": rtype}))
return env
# ---------------------------------------------------------------------------
# GATT plumbing (adapted from the proven settings transport, + ServiceData adv)
# ---------------------------------------------------------------------------
def _variant(sig: str, val: Any):
return GLib.Variant(sig, val)
def frame_payload(payload: bytes) -> list[bytes]:
if not payload:
payload = b"{}"
chunk = max(1, MAX_FRAGMENT_PAYLOAD - FRAME_HEADER.size)
chunks = [payload[i:i + chunk] for i in range(0, len(payload), chunk)] or [b""]
total, count = len(payload), len(chunks)
return [FRAME_HEADER.pack(total, idx, count) + c for idx, c in enumerate(chunks)]
@dataclass
class _Reassembly:
total_length: int
fragment_count: int
chunks: dict[int, bytes] = field(default_factory=dict)
def add(self, idx: int, payload: bytes) -> bytes | None:
self.chunks[idx] = payload
if len(self.chunks) != self.fragment_count:
return None
return b"".join(self.chunks[i] for i in range(self.fragment_count))[:self.total_length]
OM_XML = '<node><interface name="org.freedesktop.DBus.ObjectManager"><method name="GetManagedObjects"><arg type="a{oa{sa{sv}}}" name="objects" direction="out"/></method></interface></node>'
PROPS_XML = '<node><interface name="org.freedesktop.DBus.Properties"><method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method><method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method></interface></node>'
SERVICE_XML = '<node><interface name="org.bluez.GattService1"><property name="UUID" type="s" access="read"/><property name="Primary" type="b" access="read"/><property name="Characteristics" type="ao" access="read"/></interface></node>'
CHAR_XML = '<node><interface name="org.bluez.GattCharacteristic1"><method name="ReadValue"><arg type="a{sv}" direction="in"/><arg type="ay" direction="out"/></method><method name="WriteValue"><arg type="ay" direction="in"/><arg type="a{sv}" direction="in"/></method><method name="StartNotify"/><method name="StopNotify"/><property name="UUID" type="s" access="read"/><property name="Service" type="o" access="read"/><property name="Flags" type="as" access="read"/><property name="Value" type="ay" access="read"/><property name="Notifying" type="b" access="read"/></interface></node>'
ADV_XML = '<node><interface name="org.bluez.LEAdvertisement1"><method name="Release"/><property name="Type" type="s" access="read"/><property name="ServiceUUIDs" type="as" access="read"/><property name="LocalName" type="s" access="read"/><property name="ServiceData" type="a{sv}" access="read"/><property name="Includes" type="as" access="read"/></interface></node>'
class _Exported:
def __init__(self, path, xml, methods=None, properties=None):
self.path = path
self.node = Gio.DBusNodeInfo.new_for_xml(xml)
self.methods = methods or {}
self.properties = properties or {}
self.ids: list[int] = []
def register(self, bus):
for iface in self.node.interfaces:
self.ids.append(bus.register_object(self.path, iface, self._call, self._get, None))
def unregister(self, bus):
for i in self.ids:
try:
bus.unregister_object(i)
except Exception:
pass
self.ids.clear()
def _call(self, conn, sender, path, iface, method, params, invocation):
handler = self.methods.get((iface, method))
if handler is None:
invocation.return_dbus_error("org.konn3kt.Error", f"unsupported:{iface}.{method}")
return
try:
result = handler(params)
invocation.return_value(result)
except Exception as e:
invocation.return_dbus_error("org.konn3kt.Error", str(e))
def _get(self, conn, sender, path, iface, name):
props = self.properties.get(iface, {})
return props[name]() if name in props else None
class SetupBleServer:
"""Advertise + serve the setup GATT service. Single central (1:1 setup)."""
def __init__(self, *, serial: str, on_control: Callable[[bytes], bytes | None], on_request: Callable[[bytes], bytes | None]):
self.serial = serial
self.setup_id = setup_id_for_serial(serial)
self.local_name = f"IQSetup-{serial[-6:]}"
self.on_control = on_control
self.on_request = on_request
self.bus = None
self.adapter_path: str | None = None
self.context = None
self.loop = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
self._error: Exception | None = None
self.running = False
self._install_in_progress = False
self._root = f"/io/konn3kt/setup/p{os.getpid()}"
self._objects: list[_Exported] = []
self._notify = {"control": False, "response": False}
self._values = {"control": b"", "request": b"", "response": b""}
self._reassembly: dict[str, _Reassembly] = {}
# ---- lifecycle -----------------------------------------------------------
def start(self, timeout_s: float = 30.0) -> None:
if not _GI_AVAILABLE:
raise RuntimeError("gi_unavailable")
if self.running:
return
self._ensure_unique_bdaddr()
self._thread = threading.Thread(target=self._run, name="setup_ble_gatt", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=timeout_s):
raise RuntimeError("setup_ble_start_timeout")
if self._error is not None:
raise self._error
def stop(self) -> None:
try:
if self.context is not None:
GLib.idle_add(self._stop_on_loop)
if self._thread is not None:
self._thread.join(timeout=3.0)
except Exception:
pass
self.running = False
def set_install_in_progress(self, active: bool) -> None:
self._install_in_progress = bool(active)
# ---- BD address ----------------------------------------------------------
def _run_priv(self, args: list[str], timeout_s: float = 10.0) -> bool:
cmds = ([args] if os.geteuid() == 0 else [["sudo", "-n", *args], args])
for cmd in cmds:
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_s, check=False)
if p.returncode == 0:
return True
except Exception:
continue
return False
def _read_bdaddr(self) -> str | None:
try:
out = subprocess.run(["hciconfig", "hci0"], stdout=subprocess.PIPE, text=True, timeout=5.0, check=False).stdout
m = re.search(r"BD Address:\s*([0-9A-Fa-f:]{17})", out or "")
return m.group(1).upper() if m else None
except Exception:
return None
def _ensure_unique_bdaddr(self) -> None:
current = self._read_bdaddr()
if not current or not current.startswith("00:00:00:00"):
return
target = _derive_setup_bdaddr(self.serial)
octets = [f"0x{p}" for p in target.split(":")]
if not self._run_priv(["hcitool", "-i", "hci0", "cmd", "0x3f", "0x0014", *octets]):
return
time.sleep(0.5)
self._run_priv(["hciconfig", "hci0", "reset"])
time.sleep(1.5)
self._run_priv(["systemctl", "restart", "bluetooth.service"], timeout_s=20.0)
time.sleep(2.0)
# ---- main loop -----------------------------------------------------------
def _run(self):
self.context = GLib.MainContext()
self.loop = GLib.MainLoop.new(self.context, False)
self.context.push_thread_default()
try:
src = GLib.idle_source_new()
src.set_callback(self._startup)
src.attach(self.context)
self.loop.run()
except Exception as e:
self._error = e
self._ready.set()
finally:
try:
self.context.pop_thread_default()
except Exception:
pass
def _find_adapter(self) -> str | None:
objs = self._managed_objects()
for path, ifaces in objs.items():
if GATT_MANAGER_IFACE in ifaces and LE_ADV_MANAGER_IFACE in ifaces and ADAPTER_IFACE in ifaces:
return path
return None
def _managed_objects(self) -> dict:
reply = self.bus.call_sync(BLUEZ_SERVICE, "/", DBUS_OM_IFACE, "GetManagedObjects", None,
GLib.VariantType.new("(a{oa{sa{sv}}})"), Gio.DBusCallFlags.NONE, 5000, None)
u = reply.unpack()
return u[0] if isinstance(u, tuple) else u
def _startup(self, *_):
try:
self._dbg("startup begin")
self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
# ensure powered
try:
self.adapter_path = None
start = time.monotonic()
while time.monotonic() - start < ADAPTER_WAIT_TIMEOUT_S:
self.adapter_path = self._find_adapter()
if self.adapter_path:
break
time.sleep(0.5)
if not self.adapter_path:
raise RuntimeError("bluetooth_adapter_not_found")
self._set_powered()
except Exception:
raise
self._dbg("adapter=%s, registering objects" % self.adapter_path)
self._register_objects()
self._dbg("objects registered, calling RegisterApplication")
self._register_with_bluez()
self._dbg("RegisterApplication call issued")
except Exception as e:
import traceback as _tb
self._dbg("startup EXC: %r\n%s" % (e, _tb.format_exc()))
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return False
def _set_powered(self):
try:
self.bus.call_sync(BLUEZ_SERVICE, self.adapter_path, DBUS_PROPS_IFACE, "Set",
GLib.Variant("(ssv)", (ADAPTER_IFACE, "Powered", GLib.Variant("b", True))),
None, Gio.DBusCallFlags.NONE, 5000, None)
except Exception:
pass
# paths
@property
def _app_path(self): return self._root
@property
def _service_path(self): return self._root + "/service0"
@property
def _control_path(self): return self._service_path + "/char0"
@property
def _request_path(self): return self._service_path + "/char1"
@property
def _response_path(self): return self._service_path + "/char2"
@property
def _adv_path(self): return self._root + "/advertisement0"
def _char_props(self, uuid, flags, kind):
return {GATT_CHRC_IFACE: {
"UUID": lambda: _variant("s", uuid),
"Service": lambda: _variant("o", self._service_path),
"Flags": lambda: _variant("as", flags),
"Value": lambda: _variant("ay", list(self._values[kind])),
"Notifying": lambda: _variant("b", self._notify.get(kind, False)),
}}
def _service_data_variant(self):
flags = 0x01 | (0x02 if self._install_in_progress else 0x00)
data = bytes([PROTOCOL_VERSION, flags]) + bytes.fromhex(self.setup_id)
return _variant("a{sv}", {SETUP_SERVICE_DATA_UUID: GLib.Variant("ay", list(data))})
def _register_objects(self):
chars = [self._control_path, self._request_path, self._response_path]
service_props = {GATT_SERVICE_IFACE: {
"UUID": lambda: _variant("s", SETUP_SERVICE_UUID),
"Primary": lambda: _variant("b", True),
"Characteristics": lambda: _variant("ao", chars),
}}
# Keep the adv within the 31-byte legacy budget: 16-bit ServiceData (setupId)
# + a short LocalName, no 128-bit ServiceUUID, no tx-power. The app scans by
# the ServiceData UUID and matches setupId locally against its known serials.
adv_props = {LE_ADV_IFACE: {
"Type": lambda: _variant("s", "peripheral"),
"LocalName": lambda: _variant("s", "IQSetup"),
"ServiceData": self._service_data_variant,
}}
def mk_props(path, pmap):
def get_prop(params):
iface, name = params.unpack()
getter = pmap.get(iface, {}).get(name)
if getter is None:
raise RuntimeError(f"unknown_property:{iface}.{name}")
return GLib.Variant("(v)", (getter(),))
def get_all(params):
(iface,) = params.unpack()
pm = pmap.get(iface)
if pm is None:
raise RuntimeError(f"unknown_interface:{iface}")
return GLib.Variant("(a{sv})", ({k: g() for k, g in pm.items()},))
return _Exported(path, PROPS_XML, methods={(DBUS_PROPS_IFACE, "Get"): get_prop, (DBUS_PROPS_IFACE, "GetAll"): get_all})
app = _Exported(self._app_path, OM_XML, methods={(DBUS_OM_IFACE, "GetManagedObjects"): self._get_managed})
service = _Exported(self._service_path, SERVICE_XML, properties=service_props)
control = self._mk_char("control", self._control_path, SETUP_CONTROL_CHAR_UUID, ["write", "notify"])
request = self._mk_char("request", self._request_path, SETUP_REQUEST_CHAR_UUID, ["write", "write-without-response"])
response = self._mk_char("response", self._response_path, SETUP_RESPONSE_CHAR_UUID, ["notify"])
adv = _Exported(self._adv_path, ADV_XML, methods={(LE_ADV_IFACE, "Release"): lambda _: None}, properties=adv_props)
self._objects = [
app, mk_props(self._app_path, {}),
service, mk_props(self._service_path, service_props),
control, mk_props(self._control_path, self._char_props(SETUP_CONTROL_CHAR_UUID, ["write", "notify"], "control")),
request, mk_props(self._request_path, self._char_props(SETUP_REQUEST_CHAR_UUID, ["write", "write-without-response"], "request")),
response, mk_props(self._response_path, self._char_props(SETUP_RESPONSE_CHAR_UUID, ["notify"], "response")),
adv, mk_props(self._adv_path, adv_props),
]
for o in self._objects:
o.register(self.bus)
def _mk_char(self, kind, path, uuid, flags):
def read_value(_params):
return GLib.Variant("(ay)", (list(self._values[kind]),))
def write_value(params):
value, options = params.unpack()
payload = bytes(int(x) & 0xFF for x in value) if isinstance(value, (list, tuple)) else bytes(value or b"")
full = self._consume_fragment(kind, payload)
if full is None:
return None
self._values[kind] = full
if kind == "control":
resp = self.on_control(full)
if resp:
self.notify("control", resp)
elif kind == "request":
# Handle on a worker so a slow op (wifi scan, connect) never stalls BLE.
threading.Thread(target=self._handle_request_worker, args=(full,), daemon=True).start()
return None
def start_notify(_params):
self._notify[kind] = True
return None
def stop_notify(_params):
self._notify[kind] = False
return None
return _Exported(path, CHAR_XML, methods={
(GATT_CHRC_IFACE, "ReadValue"): read_value,
(GATT_CHRC_IFACE, "WriteValue"): write_value,
(GATT_CHRC_IFACE, "StartNotify"): start_notify,
(GATT_CHRC_IFACE, "StopNotify"): stop_notify,
}, properties=self._char_props(uuid, flags, kind))
def _handle_request_worker(self, full: bytes):
try:
resp = self.on_request(full)
if resp:
self.notify("response", resp)
except Exception:
pass
def _get_managed(self, _params):
managed = {
self._service_path: {GATT_SERVICE_IFACE: {
"UUID": _variant("s", SETUP_SERVICE_UUID), "Primary": _variant("b", True),
"Characteristics": _variant("ao", [self._control_path, self._request_path, self._response_path])}},
self._control_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_CONTROL_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["write", "notify"])}},
self._request_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_REQUEST_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["write", "write-without-response"])}},
self._response_path: {GATT_CHRC_IFACE: {"UUID": _variant("s", SETUP_RESPONSE_CHAR_UUID), "Service": _variant("o", self._service_path), "Flags": _variant("as", ["notify"])}},
}
return GLib.Variant("(a{oa{sa{sv}}})", (managed,))
def _consume_fragment(self, kind, fragment):
if len(fragment) < FRAME_HEADER.size:
raise RuntimeError("fragment_too_small")
total, idx, count = FRAME_HEADER.unpack(fragment[:FRAME_HEADER.size])
if count <= 0 or idx >= count:
raise RuntimeError("invalid_fragment_header")
st = self._reassembly.get(kind)
if st is None or st.total_length != total or st.fragment_count != count:
st = _Reassembly(total_length=total, fragment_count=count)
self._reassembly[kind] = st
payload = st.add(idx, fragment[FRAME_HEADER.size:])
if payload is not None:
self._reassembly.pop(kind, None)
return payload
def notify(self, kind: str, payload: bytes):
path = {"control": self._control_path, "response": self._response_path}.get(kind)
if not self.running or self.bus is None or not self._notify.get(kind) or path is None:
return
for fragment in frame_payload(payload):
self._values[kind] = fragment
try:
self.bus.emit_signal(None, path, DBUS_PROPS_IFACE, "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (GATT_CHRC_IFACE, {"Value": _variant("ay", list(fragment))}, [])))
except Exception:
pass
def refresh_advertisement(self):
"""Re-emit ServiceData (e.g. install-in-progress flag flipped)."""
if not self.running or self.bus is None:
return
try:
self.bus.emit_signal(None, self._adv_path, DBUS_PROPS_IFACE, "PropertiesChanged",
GLib.Variant("(sa{sv}as)", (LE_ADV_IFACE, {"ServiceData": self._service_data_variant()}, [])))
except Exception:
pass
# ---- bluez registration --------------------------------------------------
def _register_with_bluez(self):
self.bus.call(BLUEZ_SERVICE, self.adapter_path, GATT_MANAGER_IFACE, "RegisterApplication",
GLib.Variant("(oa{sv})", (self._app_path, {})), None, Gio.DBusCallFlags.NONE,
BLUEZ_REGISTER_TIMEOUT_MS, None, self._on_app_registered, None)
def _on_app_registered(self, conn, result, _ud):
try:
(conn or self.bus).call_finish(result)
self._dbg("app_registered OK adv_path=%s" % self._adv_path)
except GLib.GError as e:
self._dbg("app_registered GError: %s" % e)
if "AlreadyExists" not in str(e):
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return
self.bus.call(BLUEZ_SERVICE, self.adapter_path, LE_ADV_MANAGER_IFACE, "RegisterAdvertisement",
GLib.Variant("(oa{sv})", (self._adv_path, {})), None, Gio.DBusCallFlags.NONE,
BLUEZ_REGISTER_TIMEOUT_MS, None, self._on_adv_registered, None)
def _dbg(self, msg):
try:
import os as _os
fd = _os.open("/data/setup_test/setup_ble_dbg.log", _os.O_WRONLY | _os.O_CREAT | _os.O_APPEND, 0o644)
_os.write(fd, (msg + "\n").encode())
_os.close(fd)
except Exception:
pass
def _on_adv_registered(self, conn, result, _ud):
try:
(conn or self.bus).call_finish(result)
self._dbg("adv_registered OK")
except GLib.GError as e:
self._dbg("adv_registered GError: %s" % e)
if "AlreadyExists" not in str(e):
self._error = e
self._stop_on_loop()
if self.loop:
self.loop.quit()
self._ready.set()
return
self.running = True
self._error = None
self._ready.set()
def _clear_registrations(self):
if self.bus is None or self.adapter_path is None:
return
for iface, method, arg in (
(LE_ADV_MANAGER_IFACE, "UnregisterAdvertisement", self._adv_path),
(GATT_MANAGER_IFACE, "UnregisterApplication", self._app_path),
):
try:
self.bus.call_sync(BLUEZ_SERVICE, self.adapter_path, iface, method, GLib.Variant("(o)", (arg,)),
None, Gio.DBusCallFlags.NONE, 3000, None)
except Exception:
pass
def _stop_on_loop(self):
if self.bus is not None and self.adapter_path is not None:
self._clear_registrations()
if self.bus is not None:
for o in reversed(self._objects):
try:
o.unregister(self.bus)
except Exception:
pass
self._objects.clear()
self.running = False
if self.loop:
try:
self.loop.quit()
except Exception:
pass

View File

@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
Konn3kt setup BLE controller — owns the setup session, dispatches the setup
operations against the real device (Wi-Fi via WifiManager, hardware/cellular via
HARDWARE, install via an injected trigger), and exposes the 6-digit code + a
small observable status the setup UI renders.
Runs inside the setup zipapp. See konn3kt_ble_setup_protocol.md.
"""
import json
import secrets
import threading
import time
import urllib.request
from typing import Any, Callable
from iqpilot.system.ui.lib.setup_ble import (
SetupBleServer,
SetupSessionManager,
SetupAuthError,
PROTOCOL_VERSION,
)
from iqpilot.system.ui.lib.os_update import OsUpdateCoordinator
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
IQPILOT_CHANNELS = {"release": "IQLvbs/release", "beta": "IQLvbs/beta"}
# Setup ops served over BLE (Phase A only — deliberately tiny, no shell/params).
SETUP_METHODS = {
"getSetupInfo",
"scanWifi",
"connectWifi",
"forgetWifi",
"getNetworkStatus",
"startInstall",
"getInstallProgress",
"confirmOsUpdate",
"ping",
}
def _new_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
class SetupController:
def __init__(self, *, serial: str, hardware: Any, wifi_manager: Any,
on_start_install: Callable[[str], None], version: str = ""):
self.serial = serial
self.hardware = hardware
self.wifi = wifi_manager
self.on_start_install = on_start_install
self.version = version
self._code = _new_code()
self._code_lock = threading.Lock()
self.session_manager: SetupSessionManager | None = None
self.server: SetupBleServer | None = None
# Observable UI state
self._lock = threading.Lock()
self.phone_active = False # a phone has authenticated
self.install_state = "idle" # idle|downloading|os_update_required|os_updating|installing|failed|rebooting
self.install_percent = 0
self.install_error = ""
self.os_from = "" # current IQ.OS version when an OS update is needed
self.os_to = "" # target IQ.OS version
self.os_update = OsUpdateCoordinator() # bridges install thread <-> phone confirm
self._enabled = False
# ---- code ----------------------------------------------------------------
@property
def code(self) -> str:
with self._code_lock:
return self._code
def _rotate_code(self) -> None:
with self._code_lock:
self._code = _new_code()
# ---- lifecycle -----------------------------------------------------------
def start(self) -> bool:
if self._enabled:
return True
self._enabled = True
threading.Thread(target=self._start_blocking, name="setup_ble_start", daemon=True).start()
return True
def _start_blocking(self) -> None:
self.session_manager = SetupSessionManager(self._setup_id(), lambda: self.code)
server = SetupBleServer(
serial=self.serial,
on_control=self._on_control,
on_request=self._on_request,
)
try:
server.start()
except Exception as e:
print(f"[setup_ble] start failed: {e}")
return
self.server = server
if self.wifi is not None:
try:
self.wifi.set_active(True)
except Exception:
pass
print(f"[setup_ble] advertising setupId={self._setup_id()} name=IQSetup-{self.serial[-6:]}")
def stop(self) -> None:
self._enabled = False
if self.server is not None:
try:
self.server.stop()
except Exception:
pass
self.server = None
def _setup_id(self) -> str:
from iqpilot.system.ui.lib.setup_ble import setup_id_for_serial
return setup_id_for_serial(self.serial)
# ---- observable state ----------------------------------------------------
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"phone_active": self.phone_active,
"install_state": self.install_state,
"install_percent": self.install_percent,
"install_error": self.install_error,
"osFrom": self.os_from,
"osTo": self.os_to,
}
def set_install_progress(self, state: str, percent: int = 0, error: str = "",
os_from: str | None = None, os_to: str | None = None) -> None:
with self._lock:
self.install_state = state
self.install_percent = int(percent)
self.install_error = error
if os_from is not None:
self.os_from = os_from
if os_to is not None:
self.os_to = os_to
cur_from, cur_to = self.os_from, self.os_to
if self.server is not None:
self.server.set_install_in_progress(state in ("downloading", "os_updating", "installing", "rebooting"))
self.server.refresh_advertisement()
# push unsolicited progress event to a connected phone
self.server.notify("response", json.dumps({
"type": "event", "event": "installProgress",
"state": state, "percent": int(percent), "error": error,
"osFrom": cur_from, "osTo": cur_to,
}, separators=(",", ":")).encode("utf-8"))
# ---- control (hello/auth/ping) -------------------------------------------
def _on_control(self, payload: bytes) -> bytes | None:
try:
msg = json.loads(payload.decode("utf-8"))
mtype = str(msg.get("type") or "")
client_id = "central" # single central for setup
self.session_manager.prune()
if mtype == "hello":
resp = self.session_manager.begin_hello(
client_id=client_id,
setup_id=str(msg.get("setupId") or ""),
client_nonce=str(msg.get("clientNonce") or ""),
timestamp_ms=int(msg.get("timestampMs") or 0),
)
# The code is generated once at setup start and shown persistently on the
# device screen (Chromecast-style) — it must NOT change mid-pairing, or
# the code the user is reading becomes stale the instant they connect.
return json.dumps(resp, separators=(",", ":")).encode("utf-8")
if mtype == "auth":
resp = self.session_manager.authenticate(
client_id=client_id,
session_id=str(msg.get("sessionId") or ""),
timestamp_ms=int(msg.get("timestampMs") or 0),
proof_hex=str(msg.get("proof") or ""),
)
with self._lock:
self.phone_active = True
return json.dumps(resp, separators=(",", ":")).encode("utf-8")
if mtype == "ping":
sid = str(msg.get("sessionId") or "")
self.session_manager.validate_request(client_id, sid)
return json.dumps({"type": "pong", "sessionId": sid, "timestampMs": int(time.time() * 1000)}, separators=(",", ":")).encode("utf-8")
raise SetupAuthError("unsupported_control_message")
except SetupAuthError as e:
return json.dumps({"type": "error", "error": str(e)}, separators=(",", ":")).encode("utf-8")
except Exception as e:
return json.dumps({"type": "error", "error": str(e)}, separators=(",", ":")).encode("utf-8")
# ---- request (authenticated ops) -----------------------------------------
def _on_request(self, payload: bytes) -> bytes | None:
request_id = None
session = None
seq = None
try:
msg = json.loads(payload.decode("utf-8"))
request_id = msg.get("id")
method = str(msg.get("method") or "")
# MAC must be verified over params EXACTLY as sent (None when omitted) —
# coercing to {} here would diverge from the client's canonical JSON.
params = msg.get("params")
seq = int(msg.get("seq") or 0)
session = self.session_manager.validate_signed_request(
"central", str(msg.get("sessionId") or ""), request_id, seq, method, params, str(msg.get("mac") or ""),
)
if method not in SETUP_METHODS:
raise SetupAuthError(f"method_not_allowed:{method}")
result = self._dispatch(method, params or {})
return json.dumps(self.session_manager.build_response(session, request_id, seq, result=result), separators=(",", ":")).encode("utf-8")
except SetupAuthError as e:
env = self.session_manager.build_response(session, request_id, seq, error=str(e)) if session else {"type": "error", "id": request_id, "error": str(e)}
return json.dumps(env, separators=(",", ":")).encode("utf-8")
except Exception as e:
env = self.session_manager.build_response(session, request_id, seq, error=str(e)) if session else {"type": "error", "id": request_id, "error": str(e)}
return json.dumps(env, separators=(",", ":")).encode("utf-8")
def _dispatch(self, method: str, params: dict) -> Any:
if method == "ping":
return {"ok": True}
if method == "getSetupInfo":
return self._setup_info()
if method == "scanWifi":
return {"networks": self._scan_wifi()}
if method == "connectWifi":
return self._connect_wifi(str(params.get("ssid") or ""), str(params.get("password") or ""))
if method == "forgetWifi":
return self._forget_wifi(str(params.get("ssid") or ""))
if method == "getNetworkStatus":
return self._network_status()
if method == "startInstall":
return self._start_install(str(params.get("channel") or "release"))
if method == "getInstallProgress":
s = self.snapshot()
return {"state": s["install_state"], "percent": s["install_percent"], "error": s["install_error"],
"osFrom": s["osFrom"], "osTo": s["osTo"]}
if method == "confirmOsUpdate":
# Phone approved flashing the newer IQ.OS — unblock the install thread.
self.os_update.confirm()
return {"confirmed": True}
raise SetupAuthError(f"method_not_allowed:{method}")
# ---- op implementations --------------------------------------------------
def _setup_info(self) -> dict[str, Any]:
cellular = self._cellular_status()
net = self._network_status()
voltage_ok = True
try:
v = self.hardware.get_voltage() if self.hardware else None
if v is not None:
voltage_ok = v > 8_000 # mV; matches setup low-voltage threshold spirit
except Exception:
pass
hw = "mici" if self._is_mici() else "tici"
return {
"serial": self.serial,
"setupId": self._setup_id(),
"hardware": hw,
"version": self.version,
"voltageOk": voltage_ok,
"cellular": cellular,
"wifiConnected": net["wifiConnected"],
"internetReachable": net["internetReachable"],
}
def _is_mici(self) -> bool:
try:
return getattr(self.hardware, "__class__", type("x", (), {})).__name__.lower().startswith("mici") or \
bool(getattr(self.hardware, "is_mici", lambda: False)())
except Exception:
return False
def _scan_wifi(self) -> list[dict[str, Any]]:
if self.wifi is None:
return []
try:
self.wifi.set_active(True)
except Exception:
pass
nets = []
try:
for n in self.wifi.get_networks():
nets.append({
"ssid": n.ssid,
"strength": int(n.strength),
"security": int(n.security_type),
"connected": bool(n.is_connected),
"saved": bool(n.is_saved),
})
except Exception:
pass
return nets
def _connect_wifi(self, ssid: str, password: str) -> dict[str, Any]:
if not ssid:
return {"success": False, "error": "missing_ssid"}
if self.wifi is None:
return {"success": False, "error": "wifi_unavailable"}
try:
self.wifi.connect_to_network(ssid, password)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
def _forget_wifi(self, ssid: str) -> dict[str, Any]:
if self.wifi is None:
return {"success": False, "error": "wifi_unavailable"}
try:
self.wifi.forget_connection(ssid)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
def _cellular_status(self) -> dict[str, Any]:
present = False
active = False
operator = None
try:
if self.hardware is not None and hasattr(self.hardware, "get_network_type"):
from iqpilot.system.hardware.tici.hardware import NetworkType
nt = self.hardware.get_network_type()
active = nt in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G)
present = active
if self.hardware is not None and hasattr(self.hardware, "get_sim_info"):
sim = self.hardware.get_sim_info()
present = present or bool(sim and sim.get("sim_id"))
operator = (sim or {}).get("network_type")
except Exception:
pass
return {"present": present, "active": active, "operator": operator}
def _network_status(self) -> dict[str, Any]:
wifi_connected = False
ssid = None
try:
if self.wifi is not None:
for n in self.wifi.get_networks():
if n.is_connected:
wifi_connected = True
ssid = n.ssid
break
except Exception:
pass
cellular = self._cellular_status()
internet = False
try:
urllib.request.urlopen(NETWORK_CHECK_URL, timeout=3)
internet = True
except Exception:
internet = False
return {
"wifiConnected": wifi_connected,
"ssid": ssid,
"cellularActive": cellular["active"],
"internetReachable": internet,
}
def _start_install(self, channel: str) -> dict[str, Any]:
if channel not in IQPILOT_CHANNELS:
return {"started": False, "error": "invalid_channel"}
net = self._network_status()
if not net["internetReachable"]:
return {"started": False, "error": "no_internet"}
try:
self.set_install_progress("downloading", 0)
self.on_start_install(IQPILOT_CHANNELS[channel])
return {"started": True, "channel": channel}
except Exception as e:
self.set_install_progress("failed", 0, str(e))
return {"started": False, "error": str(e)}

View File

@@ -0,0 +1,238 @@
import pyray as rl
import numpy as np
from dataclasses import dataclass
from typing import Any, Optional, cast
from iqpilot.system.ui.lib.application import gui_app, GL_VERSION
MAX_GRADIENT_COLORS = 20 # includes stops as well
@dataclass
class Gradient:
start: tuple[float, float]
end: tuple[float, float]
colors: list[rl.Color]
stops: list[float]
def __post_init__(self):
if len(self.colors) > MAX_GRADIENT_COLORS:
self.colors = self.colors[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient colors truncated to {MAX_GRADIENT_COLORS} entries")
if len(self.stops) > MAX_GRADIENT_COLORS:
self.stops = self.stops[:MAX_GRADIENT_COLORS]
print(f"Warning: Gradient stops truncated to {MAX_GRADIENT_COLORS} entries")
if not len(self.stops):
color_count = min(len(self.colors), MAX_GRADIENT_COLORS)
self.stops = [i / max(1, color_count - 1) for i in range(color_count)]
FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
uniform vec4 fillColor;
// Gradient line defined in *screen pixels*
uniform int useGradient;
uniform vec2 gradientStart; // e.g. vec2(0, 0)
uniform vec2 gradientEnd; // e.g. vec2(0, screenHeight)
uniform vec4 gradientColors[20];
uniform float gradientStops[20];
uniform int gradientColorCount;
vec4 getGradientColor(vec2 p) {
// Compute t from screen-space position
vec2 d = gradientStart - gradientEnd;
float len2 = max(dot(d, d), 1e-6);
float t = clamp(dot(p - gradientEnd, d) / len2, 0.0, 1.0);
// Clamp to range
float t0 = gradientStops[0];
float tn = gradientStops[gradientColorCount-1];
if (t <= t0) return gradientColors[0];
if (t >= tn) return gradientColors[gradientColorCount-1];
for (int i = 0; i < gradientColorCount - 1; i++) {
float a = gradientStops[i];
float b = gradientStops[i+1];
if (t >= a && t <= b) {
float k = (t - a) / max(b - a, 1e-6);
return mix(gradientColors[i], gradientColors[i+1], k);
}
}
return gradientColors[gradientColorCount-1];
}
void main() {
// TODO: do proper antialiasing
finalColor = useGradient == 1 ? getGradientColor(gl_FragCoord.xy) : fillColor;
}
"""
# Default vertex shader
VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
uniform mat4 mvp;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
UNIFORM_VEC4 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4
class ShaderState:
_instance: Any = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
if ShaderState._instance is not None:
raise Exception("This class is a singleton. Use get_instance() instead.")
self.initialized = False
self.shader = None
# Shader uniform locations
self.locations = {
'fillColor': None,
'useGradient': None,
'gradientStart': None,
'gradientEnd': None,
'gradientColors': None,
'gradientStops': None,
'gradientColorCount': None,
'mvp': None,
}
# Pre-allocated FFI objects
self.fill_color_ptr = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self.use_gradient_ptr = rl.ffi.new("int[]", [0])
self.color_count_ptr = rl.ffi.new("int[]", [0])
self.gradient_colors_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS * 4)
self.gradient_stops_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS)
def initialize(self):
if self.initialized:
return
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
# Cache all uniform locations
for uniform in self.locations.keys():
self.locations[uniform] = rl.get_shader_location(self.shader, uniform)
# Orthographic MVP (origin top-left)
proj = rl.matrix_ortho(0, gui_app.width, gui_app.height, 0, -1, 1)
rl.set_shader_value_matrix(self.shader, self.locations['mvp'], proj)
self.initialized = True
def cleanup(self):
if not self.initialized:
return
if self.shader:
rl.unload_shader(self.shader)
self.shader = None
self.initialized = False
def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
gradient: Gradient | None, origin_rect: rl.Rectangle):
assert (color is not None) != (gradient is not None), "Either color or gradient must be provided"
use_gradient = 1 if (gradient is not None and len(gradient.colors) >= 1) else 0
state.use_gradient_ptr[0] = use_gradient
rl.set_shader_value(state.shader, state.locations['useGradient'], state.use_gradient_ptr, UNIFORM_INT)
if use_gradient:
gradient = cast(Gradient, gradient)
state.color_count_ptr[0] = len(gradient.colors)
for i in range(len(gradient.colors)):
c = gradient.colors[i]
base = i * 4
state.gradient_colors_ptr[base:base + 4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0]
rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, len(gradient.colors))
for i in range(len(gradient.stops)):
s = float(gradient.stops[i])
state.gradient_stops_ptr[i] = 0.0 if s < 0.0 else 1.0 if s > 1.0 else s
rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, len(gradient.stops))
rl.set_shader_value(state.shader, state.locations['gradientColorCount'], state.color_count_ptr, UNIFORM_INT)
# Map normalized start/end to screen pixels
start_vec = rl.Vector2(origin_rect.x + gradient.start[0] * origin_rect.width, origin_rect.y + gradient.start[1] * origin_rect.height)
end_vec = rl.Vector2(origin_rect.x + gradient.end[0] * origin_rect.width, origin_rect.y + gradient.end[1] * origin_rect.height)
rl.set_shader_value(state.shader, state.locations['gradientStart'], start_vec, UNIFORM_VEC2)
rl.set_shader_value(state.shader, state.locations['gradientEnd'], end_vec, UNIFORM_VEC2)
else:
color = color or rl.WHITE
state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0]
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
"""Only supports simple polygons with two chains (ribbon)."""
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
# 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):
tri_strip.append(pts[i])
tri_strip.append(pts[-i - 1])
return cast(list, np.array(tri_strip).tolist())
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
color: Optional[rl.Color] = None, gradient: Gradient | None = None):
"""
Draw a ribbon polygon (two chains) with a triangle strip and gradient.
- Input must be [L0..Lk-1, Rk-1..R0], even count, no crossings/holes.
"""
if len(points) < 3:
return
# Initialize shader on-demand
state = ShaderState.get_instance()
state.initialize()
# Ensure (N,2) float32 contiguous array
pts = np.ascontiguousarray(points, dtype=np.float32)
assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)"
# Configure gradient shader
_configure_shader_color(state, color, gradient, origin_rect)
# Triangulate via interleaving
tri_strip = triangulate(pts)
# Draw strip, color here doesn't matter
rl.begin_shader_mode(state.shader)
rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE)
rl.end_shader_mode()
def cleanup_shader_resources():
state = ShaderState.get_instance()
state.cleanup()

View File

@@ -0,0 +1,36 @@
import pyray as rl
from iqpilot.system.ui.lib.application import FONT_SCALE, font_fallback
from iqpilot.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: 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]
# Measure normal characters without emojis, then add standard width for each found emoji
emoji = find_emoji(text)
if emoji:
non_emoji_text = ""
last_index = 0
for start, end, _ in emoji:
non_emoji_text += text[last_index:start]
last_index = end
non_emoji_text += text[last_index:]
else:
non_emoji_text = text
result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251
if emoji:
result.x += len(emoji) * font_size * FONT_SCALE
# If just emoji assume a single line height
if result.y == 0:
result.y = font_size * FONT_SCALE
_cache[key] = result
return result

View File

@@ -0,0 +1,23 @@
import pyray as rl
def gui_style_color(color: rl.Color) -> int:
value_type = rl.ffi.typeof(rl.raylib.GuiSetStyle).args[2]
return int(rl.ffi.cast(value_type, rl.color_to_int(color)))
class GuiStyleContext:
def __init__(self, styles: list[tuple[int, int, int]]):
"""styles is a list of tuples (control, prop, new_value)"""
self.styles = styles
self.prev_styles: list[tuple[int, int, int]] = []
def __enter__(self):
for control, prop, new_value in self.styles:
prev_value = rl.gui_get_style(control, prop)
self.prev_styles.append((control, prop, prev_value))
rl.gui_set_style(control, prop, new_value)
def __exit__(self, exc_type, exc_value, traceback):
for control, prop, prev_value in self.prev_styles:
rl.gui_set_style(control, prop, prev_value)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
import pyray as rl
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.application import font_fallback
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
if not word:
return []
parts = []
remaining = word
while remaining:
if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
# Binary search for the longest substring that fits
left, right = 1, len(remaining)
best_fit = 1
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
left = mid + 1
else:
right = mid - 1
# Add the part that fits
parts.append(remaining[:best_fit])
remaining = remaining[best_fit:]
return parts
_cache: dict[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)
spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
if not text or max_width <= 0:
return []
# Split text by newlines first to preserve explicit line breaks
paragraphs = text.split('\n')
all_lines: list[str] = []
for paragraph in paragraphs:
# Handle empty paragraphs (preserve empty lines)
if not paragraph.strip():
all_lines.append("")
continue
# Process each paragraph separately
words = paragraph.split()
if not words:
all_lines.append("")
continue
lines: list[str] = []
current_line: list[str] = []
for word in words:
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:
# Finish current line if it has content
if current_line:
lines.append(" ".join(current_line))
current_line = []
# Break the long word into parts
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 = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
current_line.append(word)
else:
# Start new line with this word
if current_line:
lines.append(" ".join(current_line))
current_line = [word]
# Add remaining words
if current_line:
lines.append(" ".join(current_line))
# Add all lines from this paragraph
all_lines.extend(lines)
_cache[key] = all_lines
return all_lines

187
iqpilot/system/ui/mici_reset.py Executable file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
from enum import IntEnum
import pyray as rl
from iqpilot.system.hardware import PC
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.slider import SmallSlider
from iqpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
from iqpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
TIMEOUT = 3*60
# Guard the confirm against the same tap-burst that triggers tap-to-reset on boot.
# The confirm slider stays disabled until the screen has been quiet (no touches) for
# CONFIRM_GUARD_QUIET seconds, so the triggering taps can't carry through to the wipe.
CONFIRM_GUARD_MIN = 1.0 # minimum seconds the screen must be shown
CONFIRM_GUARD_QUIET = 1.0 # seconds of no touch input before arming confirm
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
# tap-burst guard (see CONFIRM_GUARD_* above)
self._start_mono = time.monotonic()
self._last_touch_mono = self._start_mono
self._confirm_armed = False
self._confirm_slider.set_enabled(lambda: self._confirm_armed)
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):
# arm the confirm slider only after the screen has been touch-quiet for a beat,
# so the taps that triggered the reset don't carry through and confirm it
now = time.monotonic()
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
self._last_touch_mono = now
if not self._confirm_armed and (now - self._start_mono) > CONFIRM_GUARD_MIN \
and (now - self._last_touch_mono) > CONFIRM_GUARD_QUIET:
self._confirm_armed = True
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. It may be corrupted."
if not self._confirm_armed:
return "Stop touching the screen, then slide to erase all content and settings."
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
# 20 fps to match tici_reset: the reset UI runs at early boot (after uninstall) before the
# panel is ready for 60 fps. _DEFAULT_FPS is 60 on the comma 4 (mici), and the default
# 60 fps here left the comma 4 stuck on the boot logo (no reset prompt). tici/tizi default
# to 20 so they were unaffected.
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(0, 0, gui_app.width, gui_app.height)):
break
if __name__ == "__main__":
main()

931
iqpilot/system/ui/mici_setup.py Executable file
View File

@@ -0,0 +1,931 @@
#!/usr/bin/env python3
from abc import abstractmethod
import os
import re
import subprocess
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 iqpilot.common.utils import run_cmd
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.wifi_manager import WifiManager
from iqpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton,
SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton,
FullRoundedButton)
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.system.ui.widgets.slider import LargerSlider, SmallSlider
from iqpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici
from iqpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
try:
from iqpilot.cereal import log
NetworkType = log.DeviceState.NetworkType
except ImportError:
NetworkType = None
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
IQPILOT_INSTALLER_URL = "IQLvbs/release"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
TMP_INSTALL_PATH = "/data/tmppilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
# "<user>/<branch>" maps to a GitHub fork. IQ.OS uses the DRM "magic" compositor, not Wayland,
# so comma's downloaded installers (installer.comma.ai) crash on launch; clone the fork directly.
GITHUB_FORK_URL = "https://github.com/{user}/openpilot.git"
# IQ.Pilot lives on the IQ Lvbs git server; github.com/IQLvbs is DMCA'd and dead.
GIT_URL_OVERRIDES = {"IQLvbs": "https://git.konn3kt.com/IQ.Lvbs/IQ.Pilot.git"}
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 = 1.0):
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(NETWORK_CHECK_URL, method="HEAD")
urllib.request.urlopen(request, timeout=1.0)
self.network_connected.set()
if NetworkType is not None and 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
DOWNLOADING = 4
DOWNLOAD_FAILED = 5
CUSTOM_SOFTWARE_WARNING = 6
IQ_GREEN = rl.Color(16, 185, 129, 255) # konn3kt/IQ accent
class SetupBleCodePage(Widget):
"""The 6-digit pairing code the konn3kt app asks for when setting up this
device over Bluetooth. Shown on-screen (Chromecast-style) while a phone drives
setup — the code is the setup authorization."""
def __init__(self, code_getter: Callable[[], str]):
super().__init__()
self._code_getter = code_getter
self._eyebrow = UnifiedLabel("SET UP FROM YOUR PHONE", 26,
text_color=rl.Color(255, 255, 255, int(255 * 0.55)),
font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, letter_spacing=0.14)
self._code = UnifiedLabel(lambda: self._spaced_code(), 76,
text_color=rl.Color(255, 255, 255, int(255 * 0.95)),
font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
letter_spacing=0.08, elide=False, wrap_text=False)
self._hint = UnifiedLabel("Enter this code in the konn3kt app", 27,
text_color=IQ_GREEN, font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
def _spaced_code(self) -> str:
c = (self._code_getter() or "").strip()
return " ".join(c) if c else "······"
def _render(self, rect: rl.Rectangle):
self._eyebrow.render(rl.Rectangle(rect.x, rect.y + 30, rect.width, 34))
self._code.render(rl.Rectangle(rect.x, rect.y + 84, rect.width, 90))
self._hint.render(rl.Rectangle(rect.x, rect.y + rect.height - 48, rect.width, 34))
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 install\nIQ.Pilot", use_openpilot_callback)
self._openpilot_slider.set_enabled(lambda: self.enabled) # for nav stack
self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
self._custom_software_slider.set_enabled(lambda: self.enabled) # for nav stack
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_slider: bool = continue_text in ("reboot", "power off")
self._continue_button: WideRoundedButton | FullRoundedButton | SmallSlider
if self._continue_slider:
self._continue_button = SmallSlider(continue_text, confirm_callback=continue_callback)
self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed)
elif 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)
if not self._continue_slider:
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)
def show_event(self):
super().show_event()
self.reset()
@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 = round(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)
# fade out back button as slider is moved
if self._continue_slider and scroll_offset <= self._scrolled_down_offset:
self._back_button.set_opacity(1.0 - self._continue_button.slider_percentage)
self._back_button.set_visible(self._continue_button.slider_percentage < 0.99)
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
if self._continue_slider:
continue_x += 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._reboot_button.set_enabled(lambda: self.enabled) # for nav stack
self._retry_button = WideRoundedButton("retry")
self._retry_button.set_click_callback(retry_callback)
self._retry_button.set_enabled(lambda: self.enabled) # for nav stack
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 NetworkSetupPage(Widget):
def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable):
super().__init__()
self._wifi_ui = WifiUIMici(wifi_manager)
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._back_button.set_enabled(lambda: self.enabled) # for nav stack
self._wifi_button = SmallerRoundedButton("wifi")
self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui))
self._wifi_button.set_enabled(lambda: self.enabled) # for nav stack
self._continue_button = WidishRoundedButton("continue")
self._continue_button.set_enabled(False)
self._continue_button.set_click_callback(continue_callback)
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(self.enabled)
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 _render(self, _):
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,
))
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)
# BLE zero-touch setup (Phase A) — shares this WifiManager. Best-effort.
self.ble_setup = None
self._ble_pending_install_url = None
self._init_ble_setup()
self._network_monitor = NetworkConnectivityMonitor()
self._network_monitor.start()
self._prev_has_internet = False
gui_app.add_nav_stack_tick(self._nav_stack_tick)
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._network_setup_page.set_enabled(lambda: self.enabled) # for nav stack
self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback,
self._software_selection_custom_software_button_callback)
self._software_selection_page.set_enabled(lambda: self.enabled) # for nav stack
self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback)
self._download_failed_page.set_enabled(lambda: self.enabled) # for nav stack
self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue,
self._custom_software_warning_back_button_callback)
self._custom_software_warning_page.set_enabled(lambda: self.enabled) # for nav stack
self._downloading_page = DownloadingPage()
def _nav_stack_tick(self):
has_internet = self._network_monitor.network_connected.is_set()
if has_internet and not self._prev_has_internet:
gui_app.pop_widgets_to(self)
self._prev_has_internet = has_internet
def _init_ble_setup(self):
try:
from iqpilot.system.ui.lib.setup_controller import SetupController
version = ""
try:
with open("/VERSION") as f:
version = f.read().strip()
except Exception:
pass
controller = SetupController(
serial=HARDWARE.get_serial(),
hardware=HARDWARE,
wifi_manager=self._wifi_manager,
on_start_install=self._ble_request_install,
version=version,
)
self.ble_setup = controller
# controller.start() can block on BlueZ D-Bus registration (up to ~30s if
# BlueZ is unhealthy) — run it off the UI thread so the setup screen never
# stalls. The UI already guards ble_setup being None / not-yet-advertising.
def _start_ble():
try:
if not controller.start():
self.ble_setup = None
except Exception:
self.ble_setup = None
threading.Thread(target=_start_ble, name="ble_setup_start", daemon=True).start()
except Exception:
self.ble_setup = None
def _ble_request_install(self, url: str):
self._ble_pending_install_url = url
def _ble_sync(self):
if self.ble_setup is None:
return
if self._ble_pending_install_url is not None:
url = self._ble_pending_install_url
self._ble_pending_install_url = None
if self.state not in (SetupState.DOWNLOADING, SetupState.DOWNLOAD_FAILED):
self.download(url)
# Install progress is reported by the install thread (_ble_progress /
# _maybe_update_os) as the single source of truth. Do NOT push per-frame here:
# a ~60fps "downloading" push floods over the thread's os_update_required /
# installing / rebooting states so the phone never sees them (the OS-update
# confirm prompt would never appear). Only relay the terminal failure reason.
if self.state == SetupState.DOWNLOAD_FAILED:
self.ble_setup.set_install_progress("failed", 0, self.failed_reason)
def _update_state(self):
self._ble_sync()
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()
else:
self._network_setup_page.hide_event()
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.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 _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_iqpilot()
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):
if self.state == SetupState.NETWORK_SETUP:
self.download(IQPILOT_INSTALLER_URL)
elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
def handle_keyboard_result(text):
url = text.strip()
if url:
self.download(url)
keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result)
gui_app.push_widget(keyboard)
def close(self):
self._network_monitor.stop()
def render_network_setup(self, rect: rl.Rectangle):
has_internet = self._network_monitor.network_connected.is_set()
self._network_setup_page.set_has_internet(has_internet)
self._network_setup_page.render(rect)
def render_downloading(self, rect: rl.Rectangle):
self._downloading_page.set_progress(self.download_progress)
self._downloading_page.render(rect)
def use_iqpilot(self):
self._set_state(SetupState.NETWORK_SETUP)
def download(self, url: str):
self._set_state(SetupState.DOWNLOADING)
# "<user>/<branch>" maps to a GitHub fork (e.g. IQLvbs/release). Clone it directly here rather
# than fetching comma's Wayland installer, which can't run on IQ.OS's DRM compositor.
match = re.match(r"^([^/.]+)/([^/]+)$", url)
if match:
user, branch = match.group(1), match.group(2)
self.download_url = f"{user}/{branch}"
self.download_thread = threading.Thread(target=self._fork_install_thread, args=(user, branch), daemon=True)
self.download_thread.start()
return
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _ble_progress(self, state: str, percent: int = 0):
# Mirror install milestones to a phone driving setup over BLE so it can track
# the flow and hand off to Phase B. Best-effort — no-op without a BLE session.
if self.ble_setup is not None:
try:
self.ble_setup.set_install_progress(state, percent)
except Exception:
pass
def _write_setup_claim(self):
ble = self.ble_setup
if ble is None or not getattr(ble, "phone_active", False):
return
try:
import hashlib
claim_id = hashlib.sha256(f"k3setup-claim:v1:{ble.code}:{ble.serial}".encode()).hexdigest()
with open("/data/setup_claim_id", "w") as f:
f.write(claim_id)
except Exception:
pass
def _fork_install_thread(self, user: str, branch: str):
git_url = GIT_URL_OVERRIDES.get(user) or GITHUB_FORK_URL.format(user=user)
label = f"{user}/{branch}"
try:
subprocess.run(["rm", "-rf", TMP_INSTALL_PATH], check=False)
self._ble_progress("downloading", 10)
clone = subprocess.run(["git", "clone", "--depth=1",
"-b", branch, git_url, TMP_INSTALL_PATH])
if clone.returncode != 0:
self._ble_progress("failed")
self.download_failed(label, "No custom software found at this URL.")
return
self._ble_progress("downloading", 70)
subprocess.run(["git", "-C", TMP_INSTALL_PATH, "reset", "--hard", f"origin/{branch}"], check=True)
run_cmd(["rm", "-f", VALID_CACHE_PATH])
# sudo: a prior *run* install can leave root-owned .pyc here that a comma-user rm can't delete.
run_cmd(["sudo", "rm", "-rf", INSTALL_PATH])
run_cmd(["mv", TMP_INSTALL_PATH, INSTALL_PATH])
self._ble_progress("installing", 90)
# If the chosen channel targets a newer IQ.OS than we're running, flash it
# BEFORE writing continue.sh so the single reboot lands on a compatible OS.
if not self._maybe_update_os(label):
return
self._write_setup_claim()
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)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(label)
# comma.sh blocks waiting for /tmp/installer before checking for continue.sh; the real
# install is already done above, so drop a no-op installer to let it proceed and launch.
with open(INSTALLER_DESTINATION_PATH, "w") as f:
f.write("#!/bin/sh\nexit 0\n")
run_cmd(["chmod", "+x", INSTALLER_DESTINATION_PATH])
# Tell the phone we're about to reboot into the installed fork so it can
# switch to Phase B; give the event a moment to flush before the link drops.
self._ble_progress("rebooting", 100)
time.sleep(0.4)
gui_app.request_close()
except Exception:
self._ble_progress("failed")
self.download_failed(label, "Invalid URL")
def _maybe_update_os(self, label: str) -> bool:
# The freshly-installed fork pins the IQ.OS it needs in launch_env.sh. If it
# differs from what we're running, flash it now (via comma's agnos.py) so the
# upcoming single reboot lands on a compatible OS instead of dead-ending on
# "update required". Returns False (and shows the failed page) on abort.
from iqpilot.system.ui.lib.os_update import os_update_needed, run_agnos_update
try:
needed, current, required = os_update_needed(INSTALL_PATH)
except Exception:
return True # never block an install on a version-check failure
if not needed:
return True
ble = self.ble_setup
if ble is not None and getattr(ble, "phone_active", False):
ble.os_update.request(current, required)
ble.set_install_progress("os_update_required", 0, os_from=current, os_to=required)
if not ble.os_update.wait_for_confirm(timeout=300):
ble.set_install_progress("failed", error="os_update_not_confirmed")
self.download_failed(label, f"IQ.OS update to {required} was not confirmed.")
return False
def _cb(pct: int, note: str):
if ble is not None:
ble.set_install_progress("os_updating", pct, error=note, os_from=current, os_to=required)
if not run_agnos_update(INSTALL_PATH, HARDWARE.get_device_type(), _cb):
if ble is not None:
ble.set_install_progress("failed", error="os_update_failed")
self.download_failed(label, f"IQ.OS update to {required} failed. Please try again.")
return False
return True
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)
# comma's ELF installer does `rm -rf /data/openpilot` as the comma user and asserts it
# succeeds; a prior *run* install leaves root-owned __pycache__ .pyc it can't delete, so it
# aborts before continue.sh and bounces back to setup. Clear the old tree first (sudo) so the
# installer's rm succeeds.
subprocess.run(["sudo", "rm", "-rf", INSTALL_PATH], check=False)
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 IQ.Pilot 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()
gui_app.push_widget(setup)
for _ in gui_app.render():
pass
setup.close()
except Exception as e:
print(f"Setup error: {e}")
finally:
gui_app.close()
if __name__ == "__main__":
main()

201
iqpilot/system/ui/mici_updater.py Executable file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.wifi_manager import WifiManager, Network
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel
from iqpilot.system.ui.widgets.button import FullRoundedButton
from iqpilot.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)
if self.process.stdout is not None:
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()

15
iqpilot/system/ui/reset.py Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from iqpilot.system.ui.lib.application import gui_app
import iqpilot.system.ui.tici_reset as tici_reset
import iqpilot.system.ui.mici_reset as mici_reset
def main():
if gui_app.big_ui():
tici_reset.main()
else:
mici_reset.main()
if __name__ == "__main__":
main()

15
iqpilot/system/ui/setup.py Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from iqpilot.system.ui.lib.application import gui_app
def main():
if gui_app.big_ui():
import iqpilot.system.ui.tici_setup as tici_setup
tici_setup.main()
else:
import iqpilot.system.ui.mici_setup as mici_setup
mici_setup.main()
if __name__ == "__main__":
main()

169
iqpilot/system/ui/spinner.py Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
import math
import pyray as rl
import select
import sys
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.text import wrap_text
from iqpilot.system.ui.widgets import Widget
# Constants
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
LOGO_SCALE = 0.82
LOGO_SIZE = int(TEXTURE_SIZE * LOGO_SCALE)
LOGO_Y_OFFSET = int(TEXTURE_SIZE * 0.04)
MARGIN_H = 100
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
PROGRESS_START = rl.Color(0x1B, 0x94, 0x88, 0xFF)
PROGRESS_END = rl.Color(0xA3, 0x0B, 0x8C, 0xFF)
DOT_REGION_X = 0.83
DOT_REGION_Y = 0.58
DOT_BOUNCE_HEIGHT = TEXTURE_SIZE * 0.08
DOT_BOUNCE_PERIOD = 0.9
def clamp(value, min_value, max_value):
return max(min(value, max_value), min_value)
def lerp_color(start: rl.Color, end: rl.Color, t: float) -> rl.Color:
t = clamp(t, 0.0, 1.0)
return rl.Color(
int(round(start.r + (end.r - start.r) * t)),
int(round(start.g + (end.g - start.g) * t)),
int(round(start.b + (end.b - start.b) * t)),
int(round(start.a + (end.a - start.a) * t)),
)
class Spinner(Widget):
def __init__(self):
super().__init__()
self._comma_texture, self._has_bouncing_dot = self._load_center_texture()
self._progress: int | None = None
self._wrapped_lines: list[str] = []
@staticmethod
def _load_center_texture():
try:
return gui_app.texture("images/k3_spinner.png", LOGO_SIZE, LOGO_SIZE), True
except Exception:
return gui_app.texture("images/spinner_comma.png", LOGO_SIZE, LOGO_SIZE), False
def _draw_logo(self, position: rl.Vector2) -> None:
if not self._has_bouncing_dot:
rl.draw_texture_v(self._comma_texture, position, rl.WHITE)
return
width = self._comma_texture.width
height = self._comma_texture.height
dot_x = int(width * DOT_REGION_X)
dot_y = int(height * DOT_REGION_Y)
source_top = rl.Rectangle(0, 0, width, dot_y)
source_left = rl.Rectangle(0, dot_y, dot_x, height - dot_y)
source_dot = rl.Rectangle(dot_x, dot_y, width - dot_x, height - dot_y)
origin = rl.Vector2(0, 0)
rl.draw_texture_pro(self._comma_texture, source_top, rl.Rectangle(position.x, position.y, width, dot_y), origin, 0, rl.WHITE)
rl.draw_texture_pro(self._comma_texture, source_left, rl.Rectangle(position.x, position.y + dot_y, dot_x, height - dot_y), origin, 0, rl.WHITE)
phase = (rl.get_time() % DOT_BOUNCE_PERIOD) / DOT_BOUNCE_PERIOD
bounce = -4.0 * DOT_BOUNCE_HEIGHT * phase * (1.0 - phase)
rl.draw_texture_pro(self._comma_texture, source_dot,
rl.Rectangle(position.x + dot_x, position.y + dot_y + bounce, width - dot_x, height - dot_y),
origin, 0, rl.WHITE)
def set_text(self, text: str) -> None:
if text.isdigit():
self._progress = clamp(int(text), 0, 100)
self._wrapped_lines = []
else:
self._progress = None
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
def _render(self, rect: rl.Rectangle):
if self._wrapped_lines:
# Calculate total height required for spinner and text
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 = CENTERED_SPACING
center_y = rect.height / 2.0
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
center = rl.Vector2(rect.width / 2.0, center_y)
comma_position = rl.Vector2(center.x - LOGO_SIZE / 2.0, center.y - LOGO_SIZE / 2.0 + LOGO_Y_OFFSET)
self._draw_logo(comma_position)
# Display the progress bar or text based on user input
if self._progress is not None:
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
bar.width *= self._progress / 100.0
if bar.width > 0:
radius = bar.height / 2.0
if bar.width <= bar.height:
fill_color = lerp_color(PROGRESS_START, PROGRESS_END, bar.width / PROGRESS_BAR_WIDTH)
rl.draw_circle_v(rl.Vector2(bar.x + bar.width / 2.0, bar.y + radius), min(bar.width, bar.height) / 2.0, fill_color)
else:
rl.draw_circle_v(rl.Vector2(bar.x + radius, bar.y + radius), radius, PROGRESS_START)
right_color = lerp_color(PROGRESS_START, PROGRESS_END, bar.width / PROGRESS_BAR_WIDTH)
rl.draw_circle_v(rl.Vector2(bar.x + bar.width - radius, bar.y + radius), radius, right_color)
rect_x = int(bar.x + radius)
rect_y = int(bar.y)
rect_width = int(max(0, bar.width - (2.0 * radius)))
if rect_width > 0:
rl.draw_rectangle_gradient_h(rect_x, rect_y, rect_width, int(bar.height), PROGRESS_START, right_color)
elif self._wrapped_lines:
for i, line in enumerate(self._wrapped_lines):
text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE)
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
FONT_SIZE, 0.0, rl.WHITE)
def _read_stdin():
"""Non-blocking read of available lines from stdin."""
lines = []
while True:
rlist, _, _ = select.select([sys.stdin], [], [], 0.0)
if not rlist:
break
line = sys.stdin.readline().strip()
if line == "":
break
lines.append(line)
return lines
def main():
gui_app.init_window("Spinner")
spinner = Spinner()
for _ in gui_app.render():
text_list = _read_stdin()
if text_list:
spinner.set_text(text_list[-1])
spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if __name__ == "__main__":
main()

111
iqpilot/system/ui/text.py Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
import os
import re
import subprocess
import sys
import pyray as rl
from iqpilot.system.hardware import HARDWARE, PC
from iqpilot.system.ui.lib.application import BIG_UI, gui_app
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
if BIG_UI:
MARGIN = 50
SPACING = 40
FONT_SIZE = 72
LINE_HEIGHT = 80
BUTTON_SIZE = rl.Vector2(560, 160)
else:
MARGIN = 20
SPACING = 30
FONT_SIZE = 25
LINE_HEIGHT = 25
BUTTON_SIZE = rl.Vector2(280, 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
def wrap_text(text, font_size, max_width):
lines = []
font = gui_app.font()
for paragraph in text.split("\n"):
if not paragraph.strip():
# Don't add empty lines first, ensuring wrap_text("") returns []
if lines:
lines.append("")
continue
indent = re.match(r"^\s*", paragraph).group()
current_line = 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 "")
if measure_text_cached(font, test_line, font_size).x <= max_width:
current_line = test_line
else:
lines.append(current_line)
current_line = word + " "
current_line = current_line.rstrip()
if current_line:
lines.append(current_line)
return lines
class TextWindow(Widget):
def __init__(self, text: str):
super().__init__()
self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2)
self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20)
self._content_rect = rl.Rectangle(0, 0, self._textarea_rect.width - 20, len(self._wrapped_lines) * LINE_HEIGHT)
self._scroll_panel = GuiScrollPanel()
self._scroll_panel._offset_filter_y.x = -max(self._content_rect.height - self._textarea_rect.height, 0)
button_text = "Exit" if PC else "Update & Reboot"
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():
if PC:
gui_app.request_close()
return
try:
basedir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
# Detect current branch and its remote tracking ref
branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=basedir, timeout=10).decode().strip()
remote = subprocess.check_output(
["git", "config", f"branch.{branch}.remote"], cwd=basedir, timeout=10
).decode().strip() or "origin"
subprocess.run(["git", "fetch", remote], cwd=basedir, timeout=60)
subprocess.run(["git", "reset", "--hard", f"{remote}/{branch}"], cwd=basedir, timeout=30)
except Exception:
pass
gui_app.request_close()
HARDWARE.reboot()
def _render(self, rect: rl.Rectangle):
scroll = self._scroll_panel.update(self._textarea_rect, self._content_rect)
rl.begin_scissor_mode(int(self._textarea_rect.x), int(self._textarea_rect.y), int(self._textarea_rect.width), int(self._textarea_rect.height))
for i, line in enumerate(self._wrapped_lines):
position = rl.Vector2(self._textarea_rect.x, self._textarea_rect.y + scroll + i * LINE_HEIGHT)
if position.y + LINE_HEIGHT < self._textarea_rect.y or position.y > self._textarea_rect.y + self._textarea_rect.height:
continue
rl.draw_text_ex(gui_app.font(), line, position, FONT_SIZE, 0, rl.WHITE)
rl.end_scissor_mode()
button_bounds = rl.Rectangle(rect.width - MARGIN - BUTTON_SIZE.x - SPACING, rect.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y)
self._button.render(button_bounds)
if __name__ == "__main__":
text = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT
gui_app.init_window("Text Viewer")
text_window = TextWindow(text)
for _ in gui_app.render():
text_window.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))

163
iqpilot/system/ui/tici_reset.py Executable file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
from enum import IntEnum
import pyray as rl
from iqpilot.system.hardware import PC
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.widgets.label import gui_label, gui_text_box
USERDATA = "/dev/disk/by-partlabel/userdata"
NVME = "/dev/nvme0n1"
TIMEOUT = 3*60
# Guard the confirm against the same tap-burst that triggers tap-to-reset on boot.
# The confirm button stays disabled until the screen has been quiet (no touches)
# for CONFIRM_GUARD_QUIET seconds, so the triggering taps can't carry through and
# silently confirm the wipe. The user must stop, see the screen, then tap confirm.
CONFIRM_GUARD_MIN = 1.0 # minimum seconds the screen must be shown
CONFIRM_GUARD_QUIET = 1.0 # seconds of no touch input before arming confirm
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
# tap-burst guard (see CONFIRM_GUARD_* above)
self._start_mono = time.monotonic()
self._last_touch_mono = self._start_mono
self._confirm_armed = False
self._confirm_button.set_enabled(lambda: self._confirm_armed)
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 {NVME}")
os.system(f"yes | sudo mkfs.ext4 {NVME}")
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):
# arm the confirm button only after the screen has been touch-quiet for a beat,
# so the taps that triggered the reset don't carry through and confirm it
now = time.monotonic()
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
self._last_touch_mono = now
if not self._confirm_armed and (now - self._start_mono) > CONFIRM_GUARD_MIN \
and (now - self._last_touch_mono) > CONFIRM_GUARD_QUIET:
self._confirm_armed = True
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."
if not self._confirm_armed and self._reset_state == ResetState.NONE:
return "System reset triggered. Stop touching the screen, then press confirm to erase all content and settings. Press cancel to resume boot."
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()

765
iqpilot/system/ui/tici_setup.py Executable file
View File

@@ -0,0 +1,765 @@
#!/usr/bin/env python3
import os
import re
import subprocess
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 iqpilot.common.utils import run_cmd
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
from iqpilot.system.ui.widgets.keyboard import Keyboard
from iqpilot.system.ui.widgets.label import Label
from iqpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
try:
from iqpilot.cereal import log
NetworkType = log.DeviceState.NetworkType
except ImportError:
NetworkType = None
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
NETWORK_CHECK_URL = "https://openpilot.comma.ai"
IQPILOT_BETA_URL = "IQLvbs/beta"
IQPILOT_RELEASE_URL = "IQLvbs/release"
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
CONTINUE_PATH = "/data/continue.sh"
TMP_CONTINUE_PATH = "/data/continue.sh.new"
INSTALL_PATH = "/data/openpilot"
TMP_INSTALL_PATH = "/data/tmppilot"
VALID_CACHE_PATH = "/data/.openpilot_cache"
INSTALLER_SOURCE_PATH = "/usr/comma/installer"
INSTALLER_DESTINATION_PATH = "/tmp/installer"
INSTALLER_URL_PATH = "/tmp/installer_url"
GITHUB_FORK_URL = "https://github.com/{user}/openpilot.git"
# IQ.Pilot lives on the IQ Lvbs git server; github.com/IQLvbs is DMCA'd and dead.
GIT_URL_OVERRIDES = {"IQLvbs": "https://git.konn3kt.com/IQ.Lvbs/IQ.Pilot.git"}
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
IQPILOT_BRANCH_SELECTION = 8
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
# Single WifiManager shared with the BLE setup transport so a phone can scan
# and connect Wi-Fi over Bluetooth using the same NM state the on-screen UI shows.
self.wifi_manager = WifiManager()
self.wifi_ui = WifiManagerUI(self.wifi_manager)
self.keyboard = Keyboard()
# BLE zero-touch setup (Phase A): advertise so the konn3kt app can drive
# Wi-Fi + install over Bluetooth. Best-effort — on-screen setup is unaffected
# if BlueZ is unavailable.
self.ble_setup = None
self._ble_pending_install_url = None
self._init_ble_setup()
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.iqpilot_url = IQPILOT_RELEASE_URL
self._software_selection_iqpilot_button = ButtonRadio("IQ.Pilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._software_selection_openpilot_button = ButtonRadio("Upstream 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._iqpilot_branch_beta_button = ButtonRadio("Beta", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._iqpilot_branch_release_button = ButtonRadio("Release", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
self._iqpilot_branch_continue_button = Button("Continue", self._iqpilot_branch_continue_button_callback,
button_style=ButtonStyle.PRIMARY)
self._iqpilot_branch_continue_button.set_enabled(False)
self._iqpilot_branch_back_button = Button("Back", self._iqpilot_branch_back_button_callback)
self._iqpilot_branch_title_label = Label("Branch:", 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)
# Persistent "set up from your phone" banner: an eyebrow + the big pairing
# code, shown on every setup page so the konn3kt app can drive setup at any point.
self._ble_eyebrow_label = Label("KONN3KT SETUP CODE", 26, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_color=rl.Color(255, 255, 255, 150))
self._ble_code_label = Label("", 56, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_color=rl.Color(255, 255, 255, 240))
self._ble_connected_label = Label("Phone connected - continue in app", 28, FontWeight.MEDIUM,
rl.GuiTextAlignment.TEXT_ALIGN_CENTER, text_color=rl.Color(16, 185, 129, 255))
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 _init_ble_setup(self):
try:
from iqpilot.system.ui.lib.setup_controller import SetupController
version = ""
try:
with open("/VERSION") as f:
version = f.read().strip()
except Exception:
pass
controller = SetupController(
serial=HARDWARE.get_serial(),
hardware=HARDWARE,
wifi_manager=self.wifi_manager,
on_start_install=self._ble_request_install,
version=version,
)
self.ble_setup = controller
# controller.start() can block on BlueZ D-Bus registration (up to ~30s if
# BlueZ is unhealthy) — run it off the UI thread so the setup screen never
# stalls. The UI already guards ble_setup being None / not-yet-advertising.
def _start_ble():
try:
if not controller.start():
self.ble_setup = None
except Exception:
self.ble_setup = None
threading.Thread(target=_start_ble, name="ble_setup_start", daemon=True).start()
except Exception:
self.ble_setup = None
def _ble_request_install(self, url: str):
# Called from the BLE thread — defer to the UI thread (raylib is not
# thread-safe). The render loop consumes this and starts the real install.
self._ble_pending_install_url = url
def _ble_sync(self):
if self.ble_setup is None:
return
# start a phone-requested install on the UI thread
if self._ble_pending_install_url is not None:
url = self._ble_pending_install_url
self._ble_pending_install_url = None
self.iqpilot_url = url
if self.state not in (SetupState.DOWNLOADING, SetupState.DOWNLOAD_FAILED):
self.download(url)
# Install progress is reported by the install thread (_ble_progress /
# _maybe_update_os) as the single source of truth. Do NOT push per-frame from
# here: a ~60fps "downloading" push floods over the install thread's
# os_update_required / installing / rebooting states and the phone never sees
# them (the OS-update confirm prompt would never appear). Only relay the
# terminal failure reason, which the thread doesn't spell out over BLE.
if self.state == SetupState.DOWNLOAD_FAILED:
self.ble_setup.set_install_progress("failed", 0, self.failed_reason)
def _render_ble_overlay(self, rect: rl.Rectangle):
# Compact top-right chip on every setup page (except while installing): the
# 6-digit pairing code so the konn3kt app can drive setup over Bluetooth, or
# the connected state once a phone has joined. Corner placement keeps it off
# the page titles (top-left) and the continue/back buttons (bottom).
if self.ble_setup is None or self.state in (SetupState.DOWNLOADING, SetupState.LOW_VOLTAGE):
return
snap = self.ble_setup.snapshot()
margin = 24
if snap.get("phone_active"):
chip_w, chip_h = 700, 64
chip_x = int(rect.x + rect.width - chip_w - margin)
chip_y = int(rect.y + margin)
chip = rl.Rectangle(chip_x, chip_y, chip_w, chip_h)
rl.draw_rectangle_rounded(chip, 0.5, 12, rl.Color(12, 12, 14, 225))
rl.draw_circle(chip_x + 42, chip_y + chip_h // 2, 8, rl.Color(16, 185, 129, 255))
self._ble_connected_label.render(rl.Rectangle(chip_x + 60, chip_y + (chip_h - 32) / 2, chip_w - 90, 36))
else:
chip_w, chip_h = 430, 132
chip_x = int(rect.x + rect.width - chip_w - margin)
chip_y = int(rect.y + margin)
chip = rl.Rectangle(chip_x, chip_y, chip_w, chip_h)
rl.draw_rectangle_rounded(chip, 0.3, 12, rl.Color(12, 12, 14, 225))
rl.draw_rectangle(chip_x, chip_y + chip_h - 3, chip_w, 3, rl.Color(16, 185, 129, 255))
code = self.ble_setup.code
spaced = " ".join(code) if code else "- - -"
self._ble_code_label.set_text(spaced)
self._ble_eyebrow_label.render(rl.Rectangle(chip_x, chip_y + 18, chip_w, 30))
self._ble_code_label.render(rl.Rectangle(chip_x, chip_y + 54, chip_w, 62))
def _render(self, rect: rl.Rectangle):
self._ble_sync()
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.IQPILOT_BRANCH_SELECTION:
self.render_iqpilot_branch_selection(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)
self._render_ble_overlay(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_iqpilot_button.selected:
self.state = SetupState.IQPILOT_BRANCH_SELECTION
elif self._software_selection_openpilot_button.selected:
self.use_openpilot()
else:
self.state = SetupState.CUSTOM_SOFTWARE_WARNING
def _iqpilot_branch_back_button_callback(self):
self.state = SetupState.SOFTWARE_SELECTION
def _iqpilot_branch_continue_button_callback(self):
self.iqpilot_url = IQPILOT_BETA_URL if self._iqpilot_branch_beta_button.selected else IQPILOT_RELEASE_URL
self.state = SetupState.NETWORK_SETUP
self.stop_network_check_thread.clear()
self.start_network_check()
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_iqpilot_button.selected:
self.download(self.iqpilot_url)
elif self._software_selection_openpilot_button.selected:
self.use_baked_installer()
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(NETWORK_CHECK_URL, timeout=2)
self.network_connected.set()
if NetworkType is not None and 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))
# three options need to fit above the bottom buttons, so they're shorter than the stock two-option layout
radio_height = 185
radio_spacing = 25
self._software_selection_continue_button.set_enabled(False)
base_y = rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2
# each radio is rendered, then immediately enforces single-selection by clearing the others;
# because each button processes its own tap during render(), the just-tapped radio always wins
iqpilot_rect = rl.Rectangle(rect.x + MARGIN, base_y, rect.width - MARGIN * 2, radio_height)
self._software_selection_iqpilot_button.render(iqpilot_rect)
if self._software_selection_iqpilot_button.selected:
self._software_selection_continue_button.set_enabled(True)
self._software_selection_openpilot_button.selected = False
self._software_selection_custom_software_button.selected = False
openpilot_rect = rl.Rectangle(rect.x + MARGIN, base_y + (radio_height + radio_spacing), 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_iqpilot_button.selected = False
self._software_selection_custom_software_button.selected = False
custom_rect = rl.Rectangle(rect.x + MARGIN, base_y + 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_iqpilot_button.selected = False
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_iqpilot_branch_selection(self, rect: rl.Rectangle):
self._iqpilot_branch_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._iqpilot_branch_continue_button.set_enabled(False)
base_y = rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2
beta_rect = rl.Rectangle(rect.x + MARGIN, base_y, rect.width - MARGIN * 2, radio_height)
self._iqpilot_branch_beta_button.render(beta_rect)
if self._iqpilot_branch_beta_button.selected:
self._iqpilot_branch_continue_button.set_enabled(True)
self._iqpilot_branch_release_button.selected = False
release_rect = rl.Rectangle(rect.x + MARGIN, base_y + (radio_height + radio_spacing), rect.width - MARGIN * 2, radio_height)
self._iqpilot_branch_release_button.render(release_rect)
if self._iqpilot_branch_release_button.selected:
self._iqpilot_branch_continue_button.set_enabled(True)
self._iqpilot_branch_beta_button.selected = False
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
button_y = rect.height - BUTTON_HEIGHT - MARGIN
self._iqpilot_branch_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
self._iqpilot_branch_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 use_baked_installer(self):
# /usr/comma/installer is the local DRM "magic" installer (clones commaai/openpilot); comma's
# downloaded Wayland installer can't initialize a display on IQ.OS, so always use the baked one.
shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
# give time for installer UI to take over
time.sleep(0.1)
gui_app.request_close()
def download(self, url: str):
self.state = SetupState.DOWNLOADING
# "<user>/<branch>" maps to a GitHub fork (e.g. IQLvbs/release-new). Clone it directly here
# rather than fetching comma's Wayland installer, which can't run on IQ.OS's DRM compositor.
match = re.match(r"^([^/.]+)/([^/]+)$", url)
if match:
user, branch = match.group(1), match.group(2)
self.download_url = f"{user}/{branch}"
self.download_thread = threading.Thread(target=self._fork_install_thread, args=(user, branch), daemon=True)
self.download_thread.start()
return
parsed = urlparse(url, scheme='https')
self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
self.download_thread.start()
def _ble_progress(self, state: str, percent: int = 0):
# Mirror install milestones to a phone driving setup over BLE so it can track
# the flow and hand off to Phase B. Best-effort — no-op without a BLE session.
if self.ble_setup is not None:
try:
self.ble_setup.set_install_progress(state, percent)
except Exception:
pass
def _write_setup_claim(self):
ble = self.ble_setup
if ble is None or not getattr(ble, "phone_active", False):
return
try:
import hashlib
claim_id = hashlib.sha256(f"k3setup-claim:v1:{ble.code}:{ble.serial}".encode()).hexdigest()
with open("/data/setup_claim_id", "w") as f:
f.write(claim_id)
except Exception:
pass
def _fork_install_thread(self, user: str, branch: str):
git_url = GIT_URL_OVERRIDES.get(user) or GITHUB_FORK_URL.format(user=user)
label = f"{user}/{branch}"
fail_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
try:
subprocess.run(["rm", "-rf", TMP_INSTALL_PATH], check=False)
self._ble_progress("downloading", 10)
clone = subprocess.run(["git", "clone", "--depth=1",
"-b", branch, git_url, TMP_INSTALL_PATH])
if clone.returncode != 0:
self._ble_progress("failed")
self.download_failed(label, "No custom software found at this URL.")
return
self._ble_progress("downloading", 70)
subprocess.run(["git", "-C", TMP_INSTALL_PATH, "reset", "--hard", f"origin/{branch}"], check=True)
run_cmd(["rm", "-f", VALID_CACHE_PATH])
# sudo: a prior *run* install can leave root-owned .pyc here that a comma-user rm can't delete.
run_cmd(["sudo", "rm", "-rf", INSTALL_PATH])
run_cmd(["mv", TMP_INSTALL_PATH, INSTALL_PATH])
self._ble_progress("installing", 90)
# If the chosen channel targets a newer IQ.OS than we're running, flash it
# BEFORE writing continue.sh so the single reboot lands on a compatible OS.
if not self._maybe_update_os(label):
return
self._write_setup_claim()
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)
with open(INSTALLER_URL_PATH, "w") as f:
f.write(label)
# comma.sh blocks waiting for /tmp/installer before it checks for continue.sh; the real
# install is already done above, so drop a no-op installer to let it proceed and launch.
with open(INSTALLER_DESTINATION_PATH, "w") as f:
f.write("#!/bin/sh\nexit 0\n")
run_cmd(["chmod", "+x", INSTALLER_DESTINATION_PATH])
# Tell the phone we're about to reboot into the installed fork so it can
# switch to Phase B; give the event a moment to flush before the link drops.
self._ble_progress("rebooting", 100)
time.sleep(0.4)
gui_app.request_close()
except Exception:
self._ble_progress("failed")
self.download_failed(label, fail_msg)
def _maybe_update_os(self, label: str) -> bool:
# The freshly-installed fork pins the IQ.OS it needs in launch_env.sh. If it
# differs from what we're running, flash it now (via comma's agnos.py) so the
# upcoming single reboot lands on a compatible OS instead of dead-ending on
# "update required". Returns False (and shows Download Failed) on abort.
from iqpilot.system.ui.lib.os_update import os_update_needed, run_agnos_update
try:
needed, current, required = os_update_needed(INSTALL_PATH)
except Exception:
return True # never block an install on a version-check failure
if not needed:
return True
ble = self.ble_setup
# When a phone is driving setup, require it to confirm the OS update. With no
# phone (on-screen-only install) proceed automatically — the fork requires it.
if ble is not None and getattr(ble, "phone_active", False):
ble.os_update.request(current, required)
ble.set_install_progress("os_update_required", 0, os_from=current, os_to=required)
if not ble.os_update.wait_for_confirm(timeout=300):
ble.set_install_progress("failed", error="os_update_not_confirmed")
self.download_failed(label, f"IQ.OS update to {required} was not confirmed.")
return False
def _cb(pct: int, note: str):
if ble is not None:
ble.set_install_progress("os_updating", pct, error=note, os_from=current, os_to=required)
if not run_agnos_update(INSTALL_PATH, HARDWARE.get_device_type(), _cb):
if ble is not None:
ble.set_install_progress("failed", error="os_update_failed")
self.download_failed(label, f"IQ.OS update to {required} failed. Please try again.")
return False
return True
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)
# comma's ELF installer does `rm -rf /data/openpilot` as the comma user and asserts it
# succeeds; a prior *run* install leaves root-owned __pycache__ .pyc it can't delete, so it
# aborts before continue.sh and bounces back to setup. Clear the old tree first (sudo) so the
# installer's rm succeeds.
subprocess.run(["sudo", "rm", "-rf", INSTALL_PATH], check=False)
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()

174
iqpilot/system/ui/tici_updater.py Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
import sys
import subprocess
import threading
import pyray as rl
from enum import IntEnum
from iqpilot.system.hardware import HARDWARE
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from iqpilot.system.ui.lib.wifi_manager import WifiManager
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.widgets.label import gui_text_box, gui_label
from iqpilot.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)
if self.process.stdout is not None:
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()

15
iqpilot/system/ui/updater.py Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
from iqpilot.system.ui.lib.application import gui_app
import iqpilot.system.ui.tici_updater as tici_updater
import iqpilot.system.ui.mici_updater as mici_updater
def main():
if gui_app.big_ui():
tici_updater.main()
else:
mici_updater.main()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,461 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from __future__ import annotations
import abc
import math
import pyray as rl
from enum import IntEnum
from typing import TypeVar
from collections.abc import Callable
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from iqpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
W = TypeVar('W', bound='Widget')
_device = None
def device_awake() -> bool:
global _device
if _device is None:
try:
from iqpilot.selfdrive.ui.ui_state import device
_device = device
except Exception:
return True
return _device.awake
class DialogResult(IntEnum):
CANCEL = 0
CONFIRM = 1
NO_ACTION = -1
class Widget(abc.ABC):
def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle | None = None
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
# if current mouse/touch down started within the widget's rectangle
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self._enabled: bool | Callable[[], bool] = True
self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False
self.__was_awake = True
self._children: list[Widget] = []
self._click_delay: float | None = None
self._click_release_time: float | None = None
def _child(self, widget: W) -> W:
"""Register a widget as a child for show/hide lifecycle propagation. Returns the widget."""
assert widget not in self._children
self._children.append(widget)
return widget
@property
def rect(self) -> rl.Rectangle:
return self._rect
def set_rect(self, rect: rl.Rectangle) -> None:
changed = (self._rect.x != rect.x or self._rect.y != rect.y or
self._rect.width != rect.width or self._rect.height != rect.height)
self._rect = rect
if changed:
self._update_layout_rects()
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
"""Can be used like size hint in QT"""
self._parent_rect = parent_rect
@property
def is_pressed(self) -> bool:
return any(self.__is_pressed) or self._click_release_time is not None
@property
def enabled(self) -> bool:
return self._enabled() if callable(self._enabled) else self._enabled
def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
self._enabled = enabled
@property
def is_visible(self) -> bool:
return self._is_visible() if callable(self._is_visible) else self._is_visible
def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
"""Set a callback to be called when the widget is clicked."""
self._click_callback = click_callback
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
"""Set a callback to determine if the widget can be clicked."""
self._touch_valid_callback = touch_callback
def _touch_valid(self) -> bool:
"""Check if the widget can be touched."""
return self._touch_valid_callback() if self._touch_valid_callback else True
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
if changed:
self._update_layout_rects()
@property
def _hit_rect(self) -> rl.Rectangle:
# restrict touches to within parent rect if set, useful inside Scroller
if self._parent_rect is None:
return self._rect
return rl.get_collision_rec(self._rect, self._parent_rect)
def render(self, rect: rl.Rectangle | None = None) -> bool | int | None:
if rect is not None:
self.set_rect(rect)
if self._click_release_time is not None and rl.get_time() >= self._click_release_time:
self._click_release_time = None
self._update_state()
if not self.is_visible:
return None
self._layout()
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
if self.enabled and self.__was_awake:
self._process_mouse_events()
else:
# Drop any in-flight press tracking when disabled (e.g. covered by a pushed widget), so a
# stale press doesn't fire when re-enabled (upstream #37094).
# TODO: ideally we emit release events when going disabled
self.__is_pressed = [False] * MAX_TOUCH_SLOTS
self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self.__was_awake = device_awake()
return ret
def _process_mouse_events(self) -> None:
hit_rect = self._hit_rect
touch_valid = self._touch_valid()
for mouse_event in gui_app.mouse_events:
if not self._multi_touch and mouse_event.slot != 0:
continue
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, hit_rect)
# Ignores touches/presses that start outside our rect
# Allows touch to leave the rect and come back in focus if mouse did not release
if mouse_event.left_pressed and touch_valid:
if mouse_in_rect:
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
self.__tracking_is_pressed[mouse_event.slot] = True
self._handle_mouse_event(mouse_event)
# Callback such as scroll panel signifies user is scrolling
elif not touch_valid:
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released:
self._handle_mouse_event(mouse_event)
if self.__is_pressed[mouse_event.slot] and mouse_in_rect:
self._handle_mouse_release(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = False
self.__tracking_is_pressed[mouse_event.slot] = False
# Mouse/touch is still within our rect
elif mouse_in_rect:
if self.__tracking_is_pressed[mouse_event.slot]:
self.__is_pressed[mouse_event.slot] = True
self._handle_mouse_event(mouse_event)
# Mouse/touch left our rect but may come back into focus later
elif not mouse_in_rect:
self.__is_pressed[mouse_event.slot] = False
self._handle_mouse_event(mouse_event)
def _layout(self) -> None:
"""Optionally lay out child widgets separately. This is called before rendering."""
def _update_state(self):
"""Optionally update the widget's non-layout state. This is called before rendering."""
@abc.abstractmethod
def _render(self, rect: rl.Rectangle) -> bool | int | None:
"""Render the widget within the given rectangle."""
def _update_layout_rects(self) -> None:
"""Optionally update any layout rects on Widget rect change."""
def _handle_mouse_press(self, mouse_pos: MousePos) -> None:
"""Optionally handle mouse press events."""
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
"""Optionally handle mouse release events."""
if self._click_delay is not None:
self._click_release_time = rl.get_time() + self._click_delay
if self._click_callback:
self._click_callback()
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
"""Optionally handle mouse events. This is called before rendering."""
# Default implementation does nothing, can be overridden by subclasses
def show_event(self):
"""Optionally handle show event. Parent must manually call this. Propagates to registered children."""
for child in self._children:
child.show_event()
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this. Propagates to registered children."""
for child in self._children:
child.hide_event()
def dismiss(self, callback: Callable[[], None] | None = None):
"""Pop this widget off the nav stack, then run the callback. Overridden by NavWidget for animation."""
gui_app.pop_widget()
if callback is not None:
callback()
SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
NAV_BAR_MARGIN = 6
NAV_BAR_WIDTH = 205
NAV_BAR_HEIGHT = 8
EDGE_SHADOW_HEIGHT = 20
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 covers_below(self) -> bool:
# Skip the widgets underneath ONLY when fully settled at the top; during a swipe/dismiss/
# slide-in render the page below so it shows through behind the moving page, like stock.
return (self._rect.y < 1.0
and self._back_button_start_pos is None
and not self._playing_dismiss_animation
and abs(self._pos_filter.velocity.x) < 0.5)
def settle_to_top(self) -> None:
# Snap to rest and clear any pending swipe/dismiss state. Called on cover/reveal so a
# drag-start captured just before this page was covered can't make it track the finger
# when it reappears (the disabled-cleanup in _update_state can't run while it's skipped).
self._pos_filter.x = 0.0
self._pos_filter.velocity.x = 0.0
self._back_button_start_pos = None
self._swiping_away = False
self._playing_dismiss_animation = False
self.set_position(self._rect.x, 0.0)
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if not self.back_enabled:
self._back_button_start_pos = None
self._swiping_away = False
self._can_swipe_away = True
return
if mouse_event.left_pressed:
# user is able to swipe away if starting near top of screen, or anywhere if scroller is at top
self._pos_filter.update_alpha(0.04)
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
scroller_at_top = False
vertical_scroller = False
# TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
if hasattr(self, '_scroller'):
scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal
vertical_scroller = not self._scroller._horizontal
elif hasattr(self, '_scroll_panel'):
scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
vertical_scroller = not self._scroll_panel._horizontal
# Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes
if (not vertical_scroller and in_dismiss_area) or scroller_at_top:
self._can_swipe_away = True
self._back_button_start_pos = mouse_event.pos
elif mouse_event.left_down:
if self._back_button_start_pos is not None:
# block swiping away if too much horizontal or upward movement
horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
if not self._swiping_away and (horizontal_movement or upward_movement):
self._can_swipe_away = False
self._back_button_start_pos = None
# block horizontal swiping if now swiping away
if self._can_swipe_away:
if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD:
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: self.enabled and 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: self.enabled and 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 not self.enabled:
self._back_button_start_pos = None
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 = None) -> bool | int | None:
ret = super().render(rect)
if self.back_enabled:
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
if self._back_button_start_pos is not None or self._playing_dismiss_animation:
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x
else:
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
self._nav_bar.render()
# draw black above widget when dismissing
if self._rect.y > 0:
shadow_height = EDGE_SHADOW_HEIGHT
shadow_y = max(math.floor(self._rect.y) - shadow_height, 0)
if shadow_y > 0:
rl.draw_rectangle(int(self._rect.x), 0, int(self._rect.width), shadow_y, rl.BLACK)
rl.draw_rectangle_gradient_v(int(self._rect.x), shadow_y,
int(self._rect.width), shadow_height + 1,
rl.BLANK, rl.Color(0, 0, 0, 204))
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()

View File

@@ -0,0 +1,303 @@
from collections.abc import Callable
from enum import IntEnum
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import Label, UnifiedLabel
from iqpilot.common.filter_simple import FirstOrderFilter
class ButtonStyle(IntEnum):
NORMAL = 0 # Most common, neutral buttons
PRIMARY = 1 # For main actions
DANGER = 2 # For critical actions, like reboot or delete
TRANSPARENT = 3 # For buttons with transparent background and border
TRANSPARENT_WHITE_TEXT = 9 # For buttons with transparent background and border and white text
TRANSPARENT_WHITE_BORDER = 10 # For buttons with transparent background and white border and text
ACTION = 4
LIST_ACTION = 5 # For list items with action buttons
NO_EFFECT = 6
KEYBOARD = 7
FORGET_WIFI = 8
ICON_PADDING = 15
DEFAULT_BUTTON_FONT_SIZE = 60
ACTION_BUTTON_FONT_SIZE = 48
BUTTON_TEXT_COLOR = {
ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255),
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE,
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.Color(228, 228, 228, 255),
ButtonStyle.ACTION: rl.BLACK,
ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255),
ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255),
ButtonStyle.KEYBOARD: rl.Color(221, 221, 221, 255),
ButtonStyle.FORGET_WIFI: rl.Color(51, 51, 51, 255),
}
BUTTON_DISABLED_TEXT_COLORS = {
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE,
}
BUTTON_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255),
ButtonStyle.DANGER: rl.Color(226, 44, 44, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLACK,
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
ButtonStyle.KEYBOARD: rl.Color(68, 68, 68, 255),
ButtonStyle.FORGET_WIFI: rl.Color(189, 189, 189, 255),
}
BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(74, 74, 74, 255),
ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255),
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
ButtonStyle.TRANSPARENT_WHITE_BORDER: rl.BLANK,
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74),
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
ButtonStyle.KEYBOARD: rl.Color(51, 51, 51, 255),
ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255),
}
BUTTON_DISABLED_BACKGROUND_COLORS = {
ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK,
}
class Button(Widget):
def __init__(self,
text: str | Callable[[], str],
click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL,
border_radius: int = 10,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_padding: int = 20,
icon=None,
elide_right: bool = False,
multi_touch: bool = False,
):
super().__init__()
self._button_style = button_style
self._border_radius = border_radius
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
self._label = Label(text, font_size, font_weight, text_alignment, text_padding=text_padding,
text_color=BUTTON_TEXT_COLOR[self._button_style], icon=icon, elide_right=elide_right)
self._click_callback = click_callback
self._multi_touch = multi_touch
def set_text(self, text):
self._label.set_text(text)
def set_button_style(self, button_style: ButtonStyle):
self._button_style = button_style
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
def _update_state(self):
if self.enabled:
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
if self.is_pressed:
self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
else:
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
elif self._button_style != ButtonStyle.NO_EFFECT:
self._background_color = BUTTON_DISABLED_BACKGROUND_COLORS.get(self._button_style, rl.Color(51, 51, 51, 255))
self._label.set_text_color(BUTTON_DISABLED_TEXT_COLORS.get(self._button_style, rl.Color(228, 228, 228, 51)))
def _render(self, _):
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
if self._button_style == ButtonStyle.TRANSPARENT_WHITE_BORDER:
rl.draw_rectangle_rounded(self._rect, roundness, 10, rl.BLACK)
rl.draw_rectangle_rounded_lines_ex(self._rect, roundness, 10, 2, rl.WHITE)
else:
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
self._label.render(self._rect)
class ButtonRadio(Button):
def __init__(self,
text: str,
icon,
click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
border_radius: int = 10,
text_padding: int = 20,
):
super().__init__(text, click_callback=click_callback, font_size=font_size,
border_radius=border_radius, text_padding=text_padding,
text_alignment=text_alignment)
self._text_padding = text_padding
self._icon = icon
self.selected = False
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.selected = not self.selected
def _update_state(self):
if self.selected:
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.PRIMARY]
else:
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.NORMAL]
def _render(self, _):
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
self._label.render(self._rect)
if self._icon and self.selected:
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
icon_x = self._rect.x + self._rect.width - self._icon.width - self._text_padding - ICON_PADDING
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE if self.enabled else rl.Color(255, 255, 255, 100))
class IconButton(Widget):
def __init__(self, texture: rl.Texture):
super().__init__()
self._texture = texture
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height))
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, rect: rl.Rectangle):
color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE
if not self.enabled:
color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(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_bg_disabled_txt = gui_app.texture("icons_mici/setup/small_button_disabled.png", 100, 100)
self._icon_txt = icon_txt
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
if not self.enabled:
bg_txt = self._icon_bg_disabled_txt
icon_white = rl.Color(255, 255, 255, int(white.a * 0.35))
else:
bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
icon_white = white
rl.draw_texture(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), icon_white)
class SmallButton(Widget):
def __init__(self, text: str):
super().__init__()
self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._load_assets()
self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._bg_disabled_txt = None
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
def set_text(self, text: str):
self._label.set_text(text)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
def _render(self, _):
if not self.enabled and self._bg_disabled_txt is not None:
rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
elif self.is_pressed:
rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
else:
rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
opacity = 0.9 if self.enabled else 0.35
self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
self._label.render(self._rect)
class SmallRedPillButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 194, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
class SmallerRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 150, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
class WideRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 316, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
class WidishRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 250, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
class FullRoundedButton(SmallButton):
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520, 100))
self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)

View File

@@ -0,0 +1,114 @@
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import DialogResult
from iqpilot.system.ui.widgets.button import ButtonStyle, Button
from iqpilot.system.ui.widgets.label import Label
from iqpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.scroller_tici import Scroller
OUTER_MARGIN = 200
RICH_OUTER_MARGIN = 100
BUTTON_HEIGHT = 160
MARGIN = 50
TEXT_PADDING = 10
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
CONFIRM_COLOR = rl.Color(16, 185, 169, 255) # teal (normal confirm)
DANGER_COLOR = rl.Color(226, 72, 58, 255) # red (destructive confirm)
BUTTON_RADIUS = 44
# Confirm actions whose button text contains one of these read as destructive (red).
DESTRUCTIVE_KEYWORDS = ("uninstall", "reset", "reboot", "power off", "forget", "delete", "remove", "erase", "wipe", "factory")
def _is_destructive(confirm_text) -> bool:
text = confirm_text() if callable(confirm_text) else confirm_text
return any(k in str(text).lower() for k in DESTRUCTIVE_KEYWORDS)
class ConfirmDialog(Widget):
def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False,
destructive: bool | None = None):
super().__init__()
if cancel_text is None:
cancel_text = tr("Cancel")
self._confirm_color = DANGER_COLOR if (destructive if destructive is not None else _is_destructive(confirm_text)) else CONFIRM_COLOR
self._label = Label(text, 70, FontWeight.BOLD, text_color=rl.Color(201, 201, 201, 255))
self._html_renderer = HtmlRenderer(text=text, text_size={ElementType.P: 50}, center_text=True)
self._cancel_button = Button(cancel_text, self._cancel_button_callback)
self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
self._cancel_button._border_radius = BUTTON_RADIUS
self._confirm_button._border_radius = BUTTON_RADIUS
self._rich = rich
self._dialog_result = DialogResult.NO_ACTION
self._cancel_text = cancel_text
self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0)
def set_text(self, text):
if not self._rich:
self._label.set_text(text)
else:
self._html_renderer.parse_html_content(text)
def reset(self):
self._dialog_result = DialogResult.NO_ACTION
def _cancel_button_callback(self):
self._dialog_result = DialogResult.CANCEL
def _confirm_button_callback(self):
self._dialog_result = DialogResult.CONFIRM
def _render(self, rect: rl.Rectangle):
dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
dialog_y = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN
dialog_width = gui_app.width - 2 * dialog_x
dialog_height = gui_app.height - 2 * dialog_y
dialog_rect = rl.Rectangle(dialog_x, dialog_y, dialog_width, dialog_height)
bottom = dialog_rect.y + dialog_rect.height
button_width = (dialog_rect.width - 3 * MARGIN) // 2
cancel_button_x = dialog_rect.x + MARGIN
confirm_button_x = dialog_rect.x + dialog_rect.width - button_width - MARGIN
button_y = bottom - BUTTON_HEIGHT - MARGIN
cancel_button = rl.Rectangle(cancel_button_x, button_y, button_width, BUTTON_HEIGHT)
confirm_button = rl.Rectangle(confirm_button_x, button_y, button_width, BUTTON_HEIGHT)
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
text_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + TEXT_PADDING,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - BUTTON_HEIGHT - MARGIN - TEXT_PADDING * 2)
if not self._rich:
self._label.render(text_rect)
else:
html_rect = rl.Rectangle(text_rect.x, text_rect.y, text_rect.width,
self._html_renderer.get_total_height(int(text_rect.width)))
self._html_renderer.set_rect(html_rect)
self._scroller.render(text_rect)
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
self._dialog_result = DialogResult.CONFIRM
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
self._dialog_result = DialogResult.CANCEL
def _render_confirm(r: rl.Rectangle):
roundness = BUTTON_RADIUS / (min(r.width, r.height) / 2)
rl.draw_rectangle_rounded(r, roundness, 10, self._confirm_color)
self._confirm_button.render(r)
if self._cancel_text:
_render_confirm(confirm_button)
self._cancel_button.render(cancel_button)
else:
full_button_width = dialog_rect.width - 2 * MARGIN
full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT)
_render_confirm(full_confirm_button)
return self._dialog_result
def alert_dialog(message: str, button_text: str | None = None):
if button_text is None:
button_text = tr("OK")
return ConfirmDialog(message, button_text, cancel_text="")

View File

@@ -0,0 +1,247 @@
from __future__ import annotations
import pyray as rl
from iqpilot.system.hardware.base import Profile
from iqpilot.system.hardware.tici.esim_manager import EsimManager, EsimOperationState, EsimUiState, get_esim_manager
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import DialogResult, Widget
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from iqpilot.system.ui.widgets.keyboard import Keyboard
from iqpilot.system.ui.widgets.list_view import ListItem, button_item, text_item
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from iqpilot.system.ui.widgets.scroller_tici import Scroller
from iqpilot.system.ui.widgets.esim_scanner import EsimQrScannerDialog
class EsimPanel(Widget):
def __init__(self):
super().__init__()
self._manager: EsimManager = get_esim_manager()
self._state = EsimUiState()
self._keyboard = Keyboard(max_text_size=256, min_text_size=0)
self._callback_registered = False
self._scroller = Scroller([])
self._rebuild_scroller()
def show_event(self):
if not self._callback_registered:
self._manager.add_callback(self._on_state_update)
self._callback_registered = True
self._manager.refresh_profiles()
def hide_event(self):
if self._callback_registered:
self._manager.remove_callback(self._on_state_update)
self._callback_registered = False
def _is_busy(self) -> bool:
return self._state.busy
def is_supported(self) -> bool:
return self._manager.is_supported()
def _on_state_update(self, state: EsimUiState):
self._state = state
self._rebuild_scroller()
def _state_text(self) -> str:
if self._state.message:
return self._state.message
return self._state.state.value
def _rebuild_scroller(self):
items: list[Widget] = [
text_item(lambda: tr("Status"), lambda: self._state_text()),
button_item(
lambda: tr("Refresh Profiles"),
lambda: tr("REFRESH"),
callback=self._on_refresh,
enabled=lambda: not self._is_busy(),
),
button_item(
lambda: tr("Add Profile"),
lambda: tr("ADD"),
description=lambda: tr("Scan QR or enter activation code"),
callback=self._on_add_profile,
enabled=lambda: not self._is_busy(),
),
]
profiles = self._state.profiles or []
for profile in profiles:
items.append(self._make_profile_item(profile))
self._scroller = Scroller(items, line_separator=True, spacing=0)
def _make_profile_item(self, profile: Profile) -> ListItem:
label = profile.nickname if profile.nickname else profile.iccid
provider = profile.provider if profile.provider else tr("Unknown provider")
description = f"{provider}\n{profile.iccid}"
action = tr("ACTIVE") if profile.enabled else tr("USE")
return button_item(
lambda label=label: label,
lambda action=action: action,
description=lambda description=description: description,
callback=lambda profile=profile: self._on_profile_selected(profile),
enabled=lambda: not self._is_busy(),
)
def _on_refresh(self):
self._manager.refresh_profiles()
def _on_add_profile(self):
options = [tr("Scan QR"), tr("Enter Code")]
dialog = MultiOptionDialog(tr("Add eSIM Profile"), options, current="")
dialog.selection = options[0]
def _done(result: int):
if result != DialogResult.CONFIRM:
return
if dialog.selection == options[0]:
self._scan_qr()
else:
self._manual_code_entry()
gui_app.set_modal_overlay(dialog, _done)
def _scan_qr(self):
scanner = EsimQrScannerDialog()
self._manager.set_scanning_state(True)
def _done(result: int):
self._manager.set_scanning_state(False)
if result != DialogResult.CONFIRM or not scanner.code:
return
self._confirm_add_code(scanner.code)
gui_app.set_modal_overlay(scanner, _done)
def _manual_code_entry(self):
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Enter Activation Code"), tr("format: LPA:1$...$..."))
self._keyboard.clear()
def _done(result: int):
if result != DialogResult.CONFIRM:
return
code = self._keyboard.text.strip()
if code:
self._confirm_add_code(code)
gui_app.set_modal_overlay(self._keyboard, _done)
def _confirm_add_code(self, code: str):
confirm = ConfirmDialog("", tr("Continue"), tr("Cancel"))
code_preview = code if len(code) < 64 else (code[:61] + "...")
confirm.set_text(tr("Use activation code:\n{}").format(code_preview))
confirm.reset()
def _done(result: int):
if result != DialogResult.CONFIRM:
return
self._prompt_nickname_and_add(code)
gui_app.set_modal_overlay(confirm, _done)
def _prompt_nickname_and_add(self, code: str):
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Optional Nickname"), tr("leave blank to skip"))
self._keyboard.clear()
def _done(result: int):
if result != DialogResult.CONFIRM:
return
nickname = self._keyboard.text.strip()
self._manager.add_profile(code, nickname if nickname else None)
gui_app.set_modal_overlay(self._keyboard, _done)
def _on_profile_selected(self, profile: Profile):
options = []
if not profile.enabled:
options.append(tr("Activate"))
options.append(tr("Rename"))
if self._manager.is_comma_profile(profile.iccid):
options.append(tr("Remove Comma pSIM"))
elif not profile.enabled:
options.append(tr("Delete"))
if len(options) == 0:
return
dialog = MultiOptionDialog(tr("Profile Actions"), options, current="")
dialog.selection = options[0]
def _done(result: int):
if result != DialogResult.CONFIRM:
return
if dialog.selection == tr("Activate"):
self._manager.switch_profile(profile.iccid)
elif dialog.selection == tr("Rename"):
self._rename_profile(profile)
elif dialog.selection == tr("Remove Comma pSIM"):
self._remove_comma_profile()
elif dialog.selection == tr("Delete"):
self._delete_profile(profile)
gui_app.set_modal_overlay(dialog, _done)
def _rename_profile(self, profile: Profile):
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Rename Profile"), "")
self._keyboard.set_text(profile.nickname or "")
def _done(result: int):
if result != DialogResult.CONFIRM:
return
name = self._keyboard.text.strip()
if name:
self._manager.rename_profile(profile.iccid, name)
gui_app.set_modal_overlay(self._keyboard, _done)
def _delete_profile(self, profile: Profile):
confirm = ConfirmDialog("", tr("Delete"), tr("Cancel"))
confirm.set_text(tr("Delete disabled profile \"{}\"?").format(profile.nickname or profile.iccid))
confirm.reset()
def _done(result: int):
if result == DialogResult.CONFIRM:
self._manager.delete_profile(profile.iccid)
gui_app.set_modal_overlay(confirm, _done)
def _remove_comma_profile(self):
confirm = ConfirmDialog("", tr("Continue"), tr("Cancel"))
confirm.set_text(
tr("This will permanently wipe the Comma pSIM profile from the SIM.")
)
confirm.reset()
def _done(result: int):
if result == DialogResult.CONFIRM:
self._remove_comma_profile_final_warning()
gui_app.set_modal_overlay(confirm, _done)
def _remove_comma_profile_final_warning(self):
confirm = ConfirmDialog("", tr("Remove"), tr("Cancel"))
confirm.set_text(
tr("After this, you must use your own eSIM profile.\n\nYou cannot use Comma Prime again unless you buy a new SIM from comma.")
)
confirm.reset()
def _done(result: int):
if result == DialogResult.CONFIRM:
self._manager.bootstrap()
gui_app.set_modal_overlay(confirm, _done)
def _render(self, _):
if not self.is_supported():
from iqpilot.system.ui.widgets.label import gui_label
gui_label(self._rect, tr("Insert the original comma SIM card that came with the device to use eSIM"), 64, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
return
self._scroller.render(self._rect)

View File

@@ -0,0 +1,131 @@
import numpy as np
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import DialogResult, NavWidget
from iqpilot.system.ui.widgets.label import gui_label
from iqpilot.selfdrive.ui.ui_state import ui_state, device
from iqpilot.system.hardware.tici.qr_decode import decode_qr, stable_code_key, validate_lpa_activation_code
if gui_app.big_ui():
from iqpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
else:
from iqpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
class EsimQrScannerDialog(NavWidget):
REQUIRED_MATCH_FRAMES = 3
DECODE_EVERY_N_FRAMES = 3
NO_CAMERA_MESSAGE_DELAY_SEC = 3.0
def __init__(self):
super().__init__()
self._is_big_ui = gui_app.big_ui()
self._camera = DriverCameraDialog() if self._is_big_ui else DriverCameraDialog(no_escape=True)
self._candidate_key = ""
self._candidate_count = 0
self.code: str | None = None
self._status = tr("Point the driver camera at a carrier eSIM QR code (LPA:...)")
self._frame_count = 0
self._no_camera_elapsed_sec = 0.0
self._result = DialogResult.NO_ACTION
self.set_back_callback(lambda: setattr(self, "_result", DialogResult.CANCEL))
def _get_frame(self):
return self._camera.frame if self._is_big_ui else self._camera._camera_view.frame
def show_event(self):
super().show_event()
self._camera.show_event()
ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True)
device.set_override_interactive_timeout(300)
self._no_camera_elapsed_sec = 0.0
def hide_event(self):
super().hide_event()
ui_state.params.put_bool("IsDriverViewEnabled", False)
device.set_override_interactive_timeout(None)
self._camera.hide_event()
def _update_state(self):
super()._update_state()
if device.awake and not ui_state.params.get_bool("IsDriverViewEnabled"):
ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True)
self._frame_count += 1
frame = self._get_frame()
if frame is None:
self._no_camera_elapsed_sec += 1.0 / gui_app.target_fps
if self._no_camera_elapsed_sec >= self.NO_CAMERA_MESSAGE_DELAY_SEC:
self._status = tr("Waiting for driver camera. Use the carrier eSIM QR code or enter the LPA code manually.")
return
self._no_camera_elapsed_sec = 0.0
if frame is None or (self._frame_count % self.DECODE_EVERY_N_FRAMES != 0):
return
try:
y = np.frombuffer(frame.data[:frame.uv_offset], dtype=np.uint8).reshape((-1, frame.stride))[:frame.height, :frame.width]
except Exception:
return
codes = decode_qr(y)
if not codes:
self._candidate_key = ""
self._candidate_count = 0
self._status = tr("No QR detected")
return
valid_payload = ""
validation_error = ""
for payload in codes:
valid, err = validate_lpa_activation_code(payload)
if valid:
valid_payload = payload
break
validation_error = err
if not valid_payload:
self._candidate_key = ""
self._candidate_count = 0
self._status = validation_error or tr("Invalid QR code")
return
key = stable_code_key(valid_payload)
if key == self._candidate_key:
self._candidate_count += 1
else:
self._candidate_key = key
self._candidate_count = 1
if self._candidate_count >= self.REQUIRED_MATCH_FRAMES:
self.code = valid_payload
self._status = tr("QR accepted")
self._result = DialogResult.CONFIRM
else:
self._status = tr("Hold steady...")
def _render(self, rect: rl.Rectangle):
self._camera.render(rect)
if self._get_frame() is None:
gui_label(
rect,
tr("camera starting"),
font_size=72 if gui_app.big_ui() else 44,
font_weight=FontWeight.BOLD,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
else:
text_rect = rl.Rectangle(rect.x + 40, rect.y + 40, rect.width - 80, 90)
gui_label(
text_rect,
self._status,
font_size=50 if gui_app.big_ui() else 30,
font_weight=FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
)
return self._result

View File

@@ -0,0 +1,302 @@
import re
import pyray as rl
from dataclasses import dataclass
from enum import Enum
from typing import Any
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from iqpilot.system.ui.lib.wrap_text import wrap_text
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.lib.text_measure import measure_text_cached
LIST_INDENT_PX = 40
class ElementType(Enum):
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
H5 = "h5"
H6 = "h6"
P = "p"
B = "b"
UL = "ul"
LI = "li"
BR = "br"
TAG_NAMES = '|'.join([t.value for t in ElementType])
START_TAG_RE = re.compile(f'<({TAG_NAMES})>')
END_TAG_RE = re.compile(f'</({TAG_NAMES})>')
COMMENT_RE = re.compile(r'<!--.*?-->', flags=re.DOTALL)
DOCTYPE_RE = re.compile(r'<!DOCTYPE[^>]*>')
HTML_BODY_TAGS_RE = re.compile(r'</?(?:html|head|body)[^>]*>')
TOKEN_RE = re.compile(r'</[^>]+>|<[^>]+>|[^<\s]+')
def is_tag(token: str) -> tuple[bool, bool, ElementType | None]:
supported_tag = bool(START_TAG_RE.fullmatch(token))
supported_end_tag = bool(END_TAG_RE.fullmatch(token))
tag = ElementType(token[1:-1].strip('/')) if supported_tag or supported_end_tag else None
return supported_tag, supported_end_tag, tag
@dataclass
class HtmlElement:
type: ElementType
content: str
font_size: int
font_weight: FontWeight
margin_top: int
margin_bottom: int
line_height: float = 0.9 # matches Qt visually, unsure why not default 1.2
indent_level: int = 0
class HtmlRenderer(Widget):
def __init__(self, file_path: str | None = None, text: str | None = None,
text_size: dict | None = None, text_color: rl.Color = rl.WHITE, center_text: bool = False):
super().__init__()
self._text_color = text_color
self._center_text = center_text
self._normal_font = gui_app.font(FontWeight.NORMAL)
self._bold_font = gui_app.font(FontWeight.BOLD)
self._indent_level = 0
if text_size is None:
text_size = {}
self._cached_height: float | None = None
self._cached_width: int = -1
# Base paragraph size (Qt stylesheet default is 48px in offroad alerts)
base_p_size = int(text_size.get(ElementType.P, 48))
# Untagged text defaults to <p>
self.styles: dict[ElementType, dict[str, Any]] = {
ElementType.H1: {"size": round(base_p_size * 2), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 16},
ElementType.H2: {"size": round(base_p_size * 1.50), "weight": FontWeight.BOLD, "margin_top": 24, "margin_bottom": 12},
ElementType.H3: {"size": round(base_p_size * 1.17), "weight": FontWeight.BOLD, "margin_top": 20, "margin_bottom": 10},
ElementType.H4: {"size": round(base_p_size * 1.00), "weight": FontWeight.BOLD, "margin_top": 16, "margin_bottom": 8},
ElementType.H5: {"size": round(base_p_size * 0.83), "weight": FontWeight.BOLD, "margin_top": 12, "margin_bottom": 6},
ElementType.H6: {"size": round(base_p_size * 0.67), "weight": FontWeight.BOLD, "margin_top": 10, "margin_bottom": 4},
ElementType.P: {"size": base_p_size, "weight": FontWeight.NORMAL, "margin_top": 8, "margin_bottom": 12},
ElementType.B: {"size": base_p_size, "weight": FontWeight.BOLD, "margin_top": 8, "margin_bottom": 12},
ElementType.LI: {"size": base_p_size, "weight": FontWeight.NORMAL, "color": rl.Color(40, 40, 40, 255), "margin_top": 6, "margin_bottom": 6},
ElementType.BR: {"size": 0, "weight": FontWeight.NORMAL, "margin_top": 0, "margin_bottom": 12},
}
self.elements: list[HtmlElement] = []
if file_path is not None:
self.parse_html_file(file_path)
elif text is not None:
self.parse_html_content(text)
else:
raise ValueError("Either file_path or text must be provided")
def parse_html_file(self, file_path: str) -> None:
with open(file_path, encoding='utf-8') as file:
content = file.read()
self.parse_html_content(content)
def parse_html_content(self, html_content: str) -> None:
self.elements.clear()
self._cached_height = None
self._cached_width = -1
# Remove HTML comments
html_content = COMMENT_RE.sub('', html_content)
# Remove DOCTYPE, html, head, body tags but keep their content
html_content = DOCTYPE_RE.sub('', html_content)
html_content = HTML_BODY_TAGS_RE.sub('', html_content)
# Parse HTML
tokens = TOKEN_RE.findall(html_content)
def close_tag():
nonlocal current_content
nonlocal current_tag
# If no tag is set, default to paragraph so we don't lose text
if current_tag is None:
current_tag = ElementType.P
text = ' '.join(current_content).strip()
current_content = []
if text:
if current_tag == ElementType.LI:
text = '' + text
self._add_element(current_tag, text)
current_content: list[str] = []
current_tag: ElementType | None = None
for token in tokens:
is_start_tag, is_end_tag, tag = is_tag(token)
if tag is not None:
if tag == ElementType.BR:
# Close current tag and add a line break
close_tag()
self._add_element(ElementType.BR, "")
elif is_start_tag or is_end_tag:
# Always add content regardless of opening or closing tag
close_tag()
if is_start_tag:
current_tag = tag
else:
current_tag = None
# increment after we add the content for the current tag
if tag == ElementType.UL:
self._indent_level = self._indent_level + 1 if is_start_tag else max(0, self._indent_level - 1)
else:
current_content.append(token)
if current_content:
close_tag()
def _add_element(self, element_type: ElementType, content: str) -> None:
style = self.styles[element_type]
element = HtmlElement(
type=element_type,
content=content,
font_size=style["size"],
font_weight=style["weight"],
margin_top=style["margin_top"],
margin_bottom=style["margin_bottom"],
indent_level=self._indent_level,
)
self.elements.append(element)
def _render(self, rect: rl.Rectangle):
# TODO: speed up by removing duplicate calculations across renders
current_y = rect.y
padding = 20
content_width = rect.width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
current_y += element.margin_bottom
continue
current_y += element.margin_top
if current_y > rect.y + rect.height:
break
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(content_width))
for line in wrapped_lines:
# Use FONT_SCALE from wrapped raylib text functions to match what is drawn
if current_y < rect.y - element.font_size * FONT_SCALE:
current_y += element.font_size * FONT_SCALE * element.line_height
continue
if current_y > rect.y + rect.height:
break
if self._center_text:
text_width = measure_text_cached(font, line, element.font_size).x
text_x = rect.x + (rect.width - text_width) / 2
else: # left align
text_x = rect.x + (max(element.indent_level - 1, 0) * LIST_INDENT_PX)
rl.draw_text_ex(font, line, rl.Vector2(text_x + padding, current_y), element.font_size, 0, self._text_color)
current_y += element.font_size * FONT_SCALE * element.line_height
# Apply bottom margin
current_y += element.margin_bottom
return current_y - rect.y
def get_total_height(self, content_width: int) -> float:
if self._cached_height is not None and self._cached_width == content_width:
return self._cached_height
total_height = 0.0
padding = 20
usable_width = content_width - (padding * 2)
for element in self.elements:
if element.type == ElementType.BR:
total_height += element.margin_bottom
continue
total_height += element.margin_top
if element.content:
font = self._get_font(element.font_weight)
wrapped_lines = wrap_text(font, element.content, element.font_size, int(usable_width))
for _ in wrapped_lines:
total_height += element.font_size * FONT_SCALE * element.line_height
total_height += element.margin_bottom
# Store result in cache
self._cached_height = total_height
self._cached_width = content_width
return total_height
def _get_font(self, weight: FontWeight):
if weight == FontWeight.BOLD:
return self._bold_font
return self._normal_font
HTML_MODAL_TEAL = rl.Color(16, 185, 169, 255)
class HtmlModal(Widget):
def __init__(self, file_path: str | None = None, text: str | None = None):
super().__init__()
self._content = HtmlRenderer(file_path=file_path, text=text)
self._scroll_panel = GuiScrollPanel()
self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None),
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
self._ok_button._border_radius = 44
def _render(self, rect: rl.Rectangle):
margin = 50
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin, rect.width - (margin * 2), rect.height - (margin * 2))
button_height = 160
button_spacing = 24
panel_height = content_rect.height - button_height - button_spacing
# Content panel
panel_rect = rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, panel_height)
rl.draw_rectangle_rounded(panel_rect, 0.03, 20, rl.Color(22, 24, 28, 255))
rl.draw_rectangle_rounded_lines_ex(panel_rect, 0.03, 20, 2, rl.Color(255, 255, 255, 26))
pad = 30
scrollable_rect = rl.Rectangle(panel_rect.x + pad, panel_rect.y + pad, panel_rect.width - 2 * pad, panel_rect.height - 2 * pad)
total_height = self._content.get_total_height(int(scrollable_rect.width))
scroll_content_rect = rl.Rectangle(scrollable_rect.x, scrollable_rect.y, scrollable_rect.width, total_height)
scroll_offset = self._scroll_panel.update(scrollable_rect, scroll_content_rect)
scroll_content_rect.y += scroll_offset
rl.begin_scissor_mode(int(scrollable_rect.x), int(scrollable_rect.y), int(scrollable_rect.width), int(scrollable_rect.height))
self._content.render(scroll_content_rect)
rl.end_scissor_mode()
button_width = (rect.width - 3 * 50) // 3
button_x = content_rect.x + content_rect.width - button_width
button_y = content_rect.y + content_rect.height - button_height
button_rect = rl.Rectangle(button_x, button_y, button_width, button_height)
rl.draw_rectangle_rounded(button_rect, 44 / (min(button_rect.width, button_rect.height) / 2), 10, HTML_MODAL_TEAL)
self._ok_button.render(button_rect)
return -1

View File

@@ -0,0 +1,232 @@
import pyray as rl
import time
from iqpilot.system.ui.lib.application import gui_app, MousePos, FONT_SCALE
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
PASSWORD_MASK_CHAR = ""
PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking
class InputBox(Widget):
def __init__(self, max_text_size=255, password_mode=False):
super().__init__()
self._max_text_size = max_text_size
self._input_text = ""
self._cursor_position = 0
self._password_mode = password_mode
self._blink_counter = 0
self._show_cursor = False
self.bg_color = rl.BLACK
self.caret_color = rl.WHITE
self._last_key_pressed = 0
self._key_press_time = 0
self._repeat_delay = 30
self._repeat_rate = 4
self._text_offset = 0
self._visible_width = 0
self._last_char_time = 0 # Track when last character was added
self._masked_length = 0 # How many characters are currently masked
@property
def text(self):
return self._input_text
@text.setter
def text(self, value):
self._input_text = value[: self._max_text_size]
self._cursor_position = len(self._input_text)
self._update_text_offset()
def set_password_mode(self, password_mode):
self._password_mode = password_mode
def clear(self):
self._input_text = ''
self._cursor_position = 0
self._text_offset = 0
def set_cursor_position(self, position):
"""Set the cursor position and reset the blink counter."""
if 0 <= position <= len(self._input_text):
self._cursor_position = position
self._blink_counter = 0
self._show_cursor = True
self._update_text_offset()
def _update_text_offset(self):
"""Ensure the cursor is visible by adjusting text offset."""
if self._visible_width == 0:
return
font = gui_app.font()
display_text = self._get_display_text()
padding = 10
if self._cursor_position > 0:
cursor_x = measure_text_cached(font, display_text[: self._cursor_position], self._font_size).x
else:
cursor_x = 0
visible_width = self._visible_width - (padding * 2)
# Adjust offset if cursor would be outside visible area
if cursor_x < self._text_offset:
self._text_offset = max(0, cursor_x - padding)
elif cursor_x > self._text_offset + visible_width:
self._text_offset = cursor_x - visible_width + padding
def add_char_at_cursor(self, char):
"""Add a character at the current cursor position."""
if len(self._input_text) < self._max_text_size:
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position + 1)
if self._password_mode:
self._last_char_time = time.monotonic()
return True
return False
def delete_char_before_cursor(self):
"""Delete the character before the cursor position (backspace)."""
if self._cursor_position > 0:
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position:]
self.set_cursor_position(self._cursor_position - 1)
return True
return False
def delete_char_at_cursor(self):
"""Delete the character at the cursor position (delete)."""
if self._cursor_position < len(self._input_text):
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1:]
self.set_cursor_position(self._cursor_position)
return True
return False
def _render(self, rect, color=None, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
if color is None:
color = self.bg_color
# Store dimensions for text offset calculations
self._visible_width = rect.width
self._font_size = font_size
# Draw input box
rl.draw_rectangle_rec(rect, color)
# Process keyboard input
self._handle_keyboard_input()
# Update cursor blink
self._blink_counter += 1
if self._blink_counter >= 30:
self._show_cursor = not self._show_cursor
self._blink_counter = 0
# Display text
font = gui_app.font()
display_text = self._get_display_text()
padding = 10
# Clip text within input box bounds
buffer = 2
rl.begin_scissor_mode(int(rect.x + padding - buffer), int(rect.y), int(rect.width - padding * 2 + buffer * 2), int(rect.height))
rl.draw_text_ex(
font,
display_text,
rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size * FONT_SCALE / 2)),
font_size,
0,
text_color,
)
# Draw cursor
if self._show_cursor:
cursor_x = rect.x + padding
if len(display_text) > 0 and self._cursor_position > 0:
cursor_x += measure_text_cached(font, display_text[: self._cursor_position], font_size).x
# Apply text offset to cursor position
cursor_x -= self._text_offset
cursor_height = font_size * FONT_SCALE + 4
cursor_y = rect.y + rect.height / 2 - cursor_height / 2
rl.draw_line(int(cursor_x), int(cursor_y), int(cursor_x), int(cursor_y + cursor_height), self.caret_color)
rl.end_scissor_mode()
def _get_display_text(self):
"""Get text to display, applying password masking with delay if needed."""
if not self._password_mode:
return self._input_text
# Show character at last edited position if within delay window
masked_text = PASSWORD_MASK_CHAR * len(self._input_text)
recent_edit = time.monotonic() - self._last_char_time < PASSWORD_MASK_DELAY
if recent_edit and self._input_text:
last_pos = max(0, self._cursor_position - 1)
if last_pos < len(self._input_text):
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1:]
return masked_text
def _handle_mouse_release(self, mouse_pos: MousePos):
# Calculate cursor position from click
if len(self._input_text) > 0:
font = gui_app.font()
display_text = self._get_display_text()
# Find the closest character position to the click
relative_x = mouse_pos.x - (self._rect.x + 10) + self._text_offset
best_pos = 0
min_distance = float('inf')
for i in range(len(self._input_text) + 1):
char_width = measure_text_cached(font, display_text[:i], self._font_size).x
distance = abs(relative_x - char_width)
if distance < min_distance:
min_distance = distance
best_pos = i
self.set_cursor_position(best_pos)
else:
self.set_cursor_position(0)
def _handle_keyboard_input(self):
# Handle navigation keys
key = rl.get_key_pressed()
if key != 0:
self._process_key(key)
if key in (rl.KEY_LEFT, rl.KEY_RIGHT, rl.KEY_BACKSPACE, rl.KEY_DELETE):
self._last_key_pressed = key
self._key_press_time = 0
# Handle repeats for held keys
elif self._last_key_pressed != 0:
if rl.is_key_down(self._last_key_pressed):
self._key_press_time += 1
if self._key_press_time > self._repeat_delay and self._key_press_time % self._repeat_rate == 0:
self._process_key(self._last_key_pressed)
else:
self._last_key_pressed = 0
# Handle text input
char = rl.get_char_pressed()
if char != 0 and char >= 32: # Filter out control characters
self.add_char_at_cursor(chr(char))
def _process_key(self, key):
if key == rl.KEY_LEFT:
if self._cursor_position > 0:
self.set_cursor_position(self._cursor_position - 1)
elif key == rl.KEY_RIGHT:
if self._cursor_position < len(self._input_text):
self.set_cursor_position(self._cursor_position + 1)
elif key == rl.KEY_BACKSPACE:
self.delete_char_before_cursor()
elif key == rl.KEY_DELETE:
self.delete_char_at_cursor()
elif key == rl.KEY_HOME:
self.set_cursor_position(0)
elif key == rl.KEY_END:
self.set_cursor_position(len(self._input_text))

View File

@@ -0,0 +1,316 @@
from functools import partial
import time
from typing import Literal
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import ButtonStyle, Button
from iqpilot.system.ui.widgets.inputbox import InputBox
from iqpilot.system.ui.widgets.label import Label
KEY_FONT_SIZE = 96
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
DELETE_REPEAT_DELAY = 0.5
DELETE_REPEAT_INTERVAL = 0.07
# Constants for special keys
CONTENT_MARGIN = 50
BACKSPACE_KEY = "<-"
ENTER_KEY = "->"
SPACE_KEY = " "
SHIFT_INACTIVE_KEY = "SHIFT_OFF"
SHIFT_ACTIVE_KEY = "SHIFT_ON"
CAPS_LOCK_KEY = "CAPS"
NUMERIC_KEY = "123"
SYMBOL_KEY = "#+="
ABC_KEY = "ABC"
# Theme
TEAL = rl.Color(16, 185, 169, 255)
KEY_LETTER = rl.Color(58, 61, 68, 255) # letters / space / punctuation
KEY_FUNCTION = rl.Color(38, 40, 46, 255) # shift, backspace, 123, ABC, #+=
KEY_DISABLED = rl.Color(34, 36, 42, 255)
KEY_RADIUS = 32
PRESS_SCALE = 1.045
FIELD_BG = rl.Color(30, 32, 38, 255)
FIELD_BORDER = rl.Color(255, 255, 255, 38)
FUNCTION_KEYS = {SHIFT_INACTIVE_KEY, SHIFT_ACTIVE_KEY, CAPS_LOCK_KEY, BACKSPACE_KEY, NUMERIC_KEY, SYMBOL_KEY, ABC_KEY}
# Define keyboard layouts as a dictionary for easier access
KEYBOARD_LAYOUTS = {
"lowercase": [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
[SHIFT_INACTIVE_KEY, "z", "x", "c", "v", "b", "n", "m", BACKSPACE_KEY],
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
],
"uppercase": [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
[SHIFT_ACTIVE_KEY, "Z", "X", "C", "V", "B", "N", "M", BACKSPACE_KEY],
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
],
"numbers": [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
[SYMBOL_KEY, "_", ",", "?", "!", "`", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
"specials": [
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", ""],
[NUMERIC_KEY, "-", ",", "?", "!", "'", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
}
class Keyboard(Widget):
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False):
super().__init__()
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
self._caps_lock = False
self._last_shift_press_time = 0
self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._max_text_size = max_text_size
self._min_text_size = min_text_size
self._input_box = InputBox(max_text_size)
self._password_mode = password_mode
self._show_password_toggle = show_password_toggle
# Backspace key repeat tracking
self._backspace_pressed: bool = False
self._backspace_press_time: float = 0.0
self._backspace_last_repeat: float = 0.0
self._render_return_status = -1
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
self._eye_open_texture = gui_app.texture("icons/eye_open.png", 81, 54)
self._eye_closed_texture = gui_app.texture("icons/eye_closed.png", 81, 54)
self._key_icons = {
BACKSPACE_KEY: gui_app.texture("icons/backspace.png", 80, 80),
SHIFT_INACTIVE_KEY: gui_app.texture("icons/shift.png", 80, 80),
SHIFT_ACTIVE_KEY: gui_app.texture("icons/shift-fill.png", 80, 80),
CAPS_LOCK_KEY: gui_app.texture("icons/capslock-fill.png", 80, 80),
ENTER_KEY: gui_app.texture("icons/arrow-right.png", 80, 80),
}
# All key buttons use a transparent style (draw no background); the keyboard paints each
# key's rounded background itself so it can shade by category and flash teal on press.
self._all_keys = {}
for l in KEYBOARD_LAYOUTS:
for keys in KEYBOARD_LAYOUTS[l]:
for key in keys:
if key in self._key_icons:
self._all_keys[key] = Button("", partial(self._key_callback, key), icon=self._key_icons[key],
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, multi_touch=True)
else:
self._all_keys[key] = Button(key, partial(self._key_callback, key),
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, font_size=85, multi_touch=True)
self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY],
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, multi_touch=True)
# Enter is a teal "Done" key
self._all_keys[ENTER_KEY] = Button(tr("Done"), partial(self._key_callback, ENTER_KEY),
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, font_size=64, multi_touch=True)
self._cancel_button._border_radius = 40
self._input_box.bg_color = rl.BLANK
self._input_box.caret_color = TEAL
def set_text(self, text: str):
self._input_box.text = text
@property
def text(self):
return self._input_box.text
def clear(self):
self._layout_name = "lowercase"
self._caps_lock = False
self._input_box.clear()
self._backspace_pressed = False
def set_title(self, title: str, sub_title: str = ""):
self._title.set_text(title)
self._sub_title.set_text(sub_title)
def _eye_button_callback(self):
self._password_mode = not self._password_mode
def _cancel_button_callback(self):
self.clear()
self._render_return_status = 0
def _key_callback(self, k):
if k == ENTER_KEY:
self._render_return_status = 1
else:
self.handle_key_press(k)
def _render(self, rect: rl.Rectangle):
rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN)
self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95))
self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))
self._cancel_button.render(rl.Rectangle(rect.x + rect.width - 386, rect.y, 386, 125))
# Draw input box and password toggle
input_margin = 25
input_box_rect = rl.Rectangle(rect.x + input_margin, rect.y + 160, rect.width - input_margin, 100)
self._render_input_area(input_box_rect)
# Process backspace key repeat if it's held down
if not self._all_keys[BACKSPACE_KEY].is_pressed:
self._backspace_pressed = False
if self._backspace_pressed:
current_time = time.monotonic()
time_since_press = current_time - self._backspace_press_time
# After initial delay, start repeating with shorter intervals
if time_since_press > DELETE_REPEAT_DELAY:
time_since_last_repeat = current_time - self._backspace_last_repeat
if time_since_last_repeat > DELETE_REPEAT_INTERVAL:
self._input_box.delete_char_before_cursor()
self._backspace_last_repeat = current_time
layout = KEYBOARD_LAYOUTS[self._layout_name]
h_space, v_space = 15, 15
row_y_start = rect.y + 300 # Starting Y position for the first row
key_height = (rect.height - 300 - 3 * v_space) / 4
key_max_width = (rect.width - (len(layout[2]) - 1) * h_space) / len(layout[2])
# Iterate over the rows of keys in the current layout
pressed_keys = []
for row, keys in enumerate(layout):
key_width = min((rect.width - (180 if row == 1 else 0) - h_space * (len(keys) - 1)) / len(keys), key_max_width)
start_x = rect.x + (90 if row == 1 else 0)
for i, key in enumerate(keys):
if i > 0:
start_x += h_space
new_width = (key_width * 3 + h_space * 2) if key == SPACE_KEY else (key_width * 2 + h_space if key == ENTER_KEY else key_width)
key_rect = rl.Rectangle(start_x, row_y_start + row * (key_height + v_space), new_width, key_height)
start_x += new_width
is_enabled = key != ENTER_KEY or len(self._input_box.text) >= self._min_text_size
if key == BACKSPACE_KEY and self._all_keys[BACKSPACE_KEY].is_pressed and not self._backspace_pressed:
self._backspace_pressed = True
self._backspace_press_time = time.monotonic()
self._backspace_last_repeat = time.monotonic()
draw_key = key
if key == SHIFT_ACTIVE_KEY and self._caps_lock:
draw_key = CAPS_LOCK_KEY
btn = self._all_keys[draw_key]
btn.set_enabled(is_enabled)
# Pressed keys are deferred so they grow + glow on top of their neighbors
if is_enabled and btn.is_pressed:
pressed_keys.append((btn, key_rect, new_width, key_height))
continue
if not is_enabled:
bg = KEY_DISABLED
elif key == ENTER_KEY or draw_key in (SHIFT_ACTIVE_KEY, CAPS_LOCK_KEY):
bg = TEAL # teal "Done" / shift / caps active
elif draw_key in FUNCTION_KEYS:
bg = KEY_FUNCTION # darker function keys
else:
bg = KEY_LETTER
roundness = min(1.0, KEY_RADIUS / (min(new_width, key_height) / 2))
rl.draw_rectangle_rounded(key_rect, roundness, 10, bg)
btn.render(key_rect)
# Draw pressed keys last: slightly grown with a teal glow
for btn, kr, w, h in pressed_keys:
gw, gh = w * PRESS_SCALE, h * PRESS_SCALE
grown = rl.Rectangle(kr.x - (gw - w) / 2, kr.y - (gh - h) / 2, gw, gh)
gr = min(1.0, KEY_RADIUS / (min(gw, gh) / 2))
glow = rl.Rectangle(grown.x - 10, grown.y - 10, grown.width + 20, grown.height + 20)
rl.draw_rectangle_rounded(glow, gr, 10, rl.Color(16, 185, 169, 70))
rl.draw_rectangle_rounded(grown, gr, 10, TEAL)
btn.render(grown)
return self._render_return_status
def _render_input_area(self, input_rect: rl.Rectangle):
# Filled rounded field behind the text
field = rl.Rectangle(input_rect.x, input_rect.y - 6, input_rect.width, input_rect.height + 24)
rl.draw_rectangle_rounded(field, 0.34, 16, FIELD_BG)
rl.draw_rectangle_rounded_lines_ex(field, 0.34, 16, 2, FIELD_BORDER)
if self._show_password_toggle:
self._input_box.set_password_mode(self._password_mode)
self._input_box.render(rl.Rectangle(input_rect.x + 24, input_rect.y, input_rect.width - 130, input_rect.height))
# render eye icon
eye_texture = self._eye_closed_texture if self._password_mode else self._eye_open_texture
eye_rect = rl.Rectangle(input_rect.x + input_rect.width - 100, input_rect.y, 80, input_rect.height)
self._eye_button.render(eye_rect)
eye_x = eye_rect.x + (eye_rect.width - eye_texture.width) / 2
eye_y = eye_rect.y + (eye_rect.height - eye_texture.height) / 2
rl.draw_texture_v(eye_texture, rl.Vector2(eye_x, eye_y), rl.WHITE)
else:
self._input_box.render(rl.Rectangle(input_rect.x + 24, input_rect.y, input_rect.width - 48, input_rect.height))
def handle_key_press(self, key):
if key in (CAPS_LOCK_KEY, ABC_KEY):
self._caps_lock = False
self._layout_name = "lowercase"
elif key == SHIFT_INACTIVE_KEY:
self._last_shift_press_time = time.monotonic()
self._layout_name = "uppercase"
elif key == SHIFT_ACTIVE_KEY:
if time.monotonic() - self._last_shift_press_time < DOUBLE_CLICK_THRESHOLD:
self._caps_lock = True
else:
self._layout_name = "lowercase"
elif key == NUMERIC_KEY:
self._layout_name = "numbers"
elif key == SYMBOL_KEY:
self._layout_name = "specials"
elif key == BACKSPACE_KEY:
self._input_box.delete_char_before_cursor()
else:
self._input_box.add_char_at_cursor(key)
if not self._caps_lock and self._layout_name == "uppercase":
self._layout_name = "lowercase"
def reset(self, min_text_size: int | None = None):
if min_text_size is not None:
self._min_text_size = min_text_size
self._render_return_status = -1
self._last_shift_press_time = 0
self._backspace_pressed = False
self._backspace_press_time = 0.0
self._backspace_last_repeat = 0.0
self.clear()
if __name__ == "__main__":
gui_app.init_window("Keyboard")
keyboard = Keyboard(min_text_size=8, show_password_toggle=True)
for _ in gui_app.render():
keyboard.set_title("Keyboard Input", "Type your text below")
result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result == 1:
print(f"You typed: {keyboard.text}")
gui_app.request_close()
elif result == 0:
print("Canceled")
gui_app.request_close()
gui_app.close()

View File

@@ -0,0 +1,863 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
from typing import Union
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.utils import GuiStyleContext, gui_style_color
from iqpilot.system.ui.lib.emoji import find_emoji, emoji_tex
from iqpilot.system.ui.lib.wrap_text import wrap_text
ICON_PADDING = 15
# TODO: make this common
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
class ScrollState(IntEnum):
STARTING = 0
SCROLLING = 1
ENDING = 2
# TODO: merge anything new here to master
class MiciLabel(Widget):
def __init__(self,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
width: int | None = 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 = 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):
if self.width != width:
self._scroll_offset = 0
self._scroll_pause_t = None
self._scroll_state = ScrollState.STARTING
self.width = width
self._rect.width = width
self.set_text(self.text)
def set_text(self, txt: str):
if self.text != txt:
self._scroll_offset = 0
self._scroll_pause_t = None
self._scroll_state = ScrollState.STARTING
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. Pause at both endpoints, then snap back to the
# readable start position instead of animating backward.
elif self.scroll and self._needs_scroll:
max_scroll = max(0.0, text_size.x - rect.width)
speed = 0.8 / 60. * gui_app.target_fps
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 = max(-max_scroll, self._scroll_offset - speed)
if self._scroll_offset <= -max_scroll:
self._scroll_state = ScrollState.ENDING
self._scroll_pause_t = None
elif self._scroll_state == ScrollState.ENDING:
if self._scroll_pause_t is None:
self._scroll_pause_t = rl.get_time() + 1.5
if rl.get_time() >= self._scroll_pause_t:
self._scroll_offset = 0
self._scroll_state = ScrollState.STARTING
self._scroll_pause_t = None
# Calculate horizontal position based on alignment
if self._needs_scroll:
text_x = rect.x + self._scroll_offset
else:
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)
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
text_width = measure_text_cached(font, self.text, self.font_size, self.spacing).x
if self._scroll_offset > -(text_width - rect.width):
rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
if self._scroll_offset < 0:
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)
rl.end_scissor_mode()
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
color: rl.Color = DEFAULT_TEXT_COLOR,
font_weight: FontWeight = FontWeight.NORMAL,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
elide_right: bool = True
):
font = gui_app.font(font_weight)
text_size = measure_text_cached(font, text, font_size)
display_text = text
# Elide text to fit within the rectangle
if elide_right and text_size.x > rect.width:
_ellipsis = "..."
left, right = 0, len(text)
while left < right:
mid = (left + right) // 2
candidate = text[:mid] + _ellipsis
candidate_size = measure_text_cached(font, candidate, font_size)
if candidate_size.x <= rect.width:
left = mid + 1
else:
right = mid
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
text_size = measure_text_cached(font, display_text, font_size)
# Calculate horizontal position based on alignment
text_x = rect.x + {
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
}.get(alignment, 0)
# Calculate vertical position based on alignment
text_y = rect.y + {
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
}.get(alignment_vertical, 0)
# Draw the text in the specified rectangle
# TODO: add wrapping and proper centering for multiline text
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
def gui_text_box(
rect: rl.Rectangle,
text: str,
font_size: int = DEFAULT_TEXT_SIZE,
color: rl.Color = DEFAULT_TEXT_COLOR,
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
font_weight: FontWeight = FontWeight.NORMAL,
line_scale: float = 1.0,
):
styles = [
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, gui_style_color(color)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
]
if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(font_weight))
with GuiStyleContext(styles):
rl.gui_label(rect, text)
if font_weight != FontWeight.NORMAL:
rl.gui_set_font(gui_app.font(FontWeight.NORMAL))
# Non-interactive text area. Can render emojis and an optional specified icon.
class Label(Widget):
def __init__(self,
text: str | Callable[[], str],
font_size: int = DEFAULT_TEXT_SIZE,
font_weight: FontWeight = FontWeight.NORMAL,
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
text_padding: int = 0,
text_color: rl.Color = DEFAULT_TEXT_COLOR,
icon: Union[rl.Texture, None] = None,
elide_right: bool = False,
line_scale=1.0,
):
super().__init__()
self._font_weight = font_weight
self._font = gui_app.font(self._font_weight)
self._font_size = font_size
self._text_alignment = text_alignment
self._text_alignment_vertical = text_alignment_vertical
self._text_padding = text_padding
self._text_color = text_color
self._icon = icon
self._elide_right = elide_right
self._line_scale = line_scale
self._text = text
self.set_text(text)
def set_text(self, text):
self._text = text
self._update_text(self._text)
def set_text_color(self, color):
self._text_color = color
def set_font_size(self, size):
self._font_size = size
self._update_text(self._text)
def _update_text(self, text):
self._emojis = []
self._text_size = []
text = _resolve_value(text)
if self._elide_right:
display_text = text
# Elide text to fit within the rectangle
text_size = measure_text_cached(self._font, text, self._font_size)
content_width = self._rect.width - self._text_padding * 2
if self._icon:
content_width -= self._icon.width + ICON_PADDING
if text_size.x > content_width:
_ellipsis = "..."
left, right = 0, len(text)
while left < right:
mid = (left + right) // 2
candidate = text[:mid] + _ellipsis
candidate_size = measure_text_cached(self._font, candidate, self._font_size)
if candidate_size.x <= content_width:
left = mid + 1
else:
right = mid
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
self._text_wrapped = [display_text]
else:
self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2)))
for t in self._text_wrapped:
self._emojis.append(find_emoji(t))
self._text_size.append(measure_text_cached(self._font, t, self._font_size))
def _render(self, _):
# Text can be a callable
# TODO: cache until text changed
self._update_text(self._text)
text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0)
if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE:
total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE
text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2))
else:
text_pos = rl.Vector2(self._rect.x, self._rect.y)
if self._icon:
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
if len(self._text_wrapped) > 0:
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
icon_x = self._rect.x + self._text_padding
text_pos.x = self._icon.width + ICON_PADDING
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
total_width = self._icon.width + ICON_PADDING + text_size.x
icon_x = self._rect.x + (self._rect.width - total_width) / 2
text_pos.x = self._icon.width + ICON_PADDING
else:
icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - ICON_PADDING - self._icon.width
else:
icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE)
for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]):
line_pos = rl.Vector2(text_pos.x, text_pos.y)
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
line_pos.x += self._text_padding
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
line_pos.x += (self._rect.width - text_size.x) // 2
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
line_pos.x += self._rect.width - text_size.x - self._text_padding
prev_index = 0
for start, end, emoji in emojis:
text_before = text[prev_index:start]
width_before = measure_text_cached(self._font, text_before, self._font_size)
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color)
line_pos.x += width_before.x
tex = emoji_tex(emoji)
if tex is not None:
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale
class UnifiedLabel(Widget):
"""
Unified label widget that combines functionality from gui_label, gui_text_box, 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,
scroll: bool = False,
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._scroll = scroll
self._line_height = line_height * 0.9
self._letter_spacing = letter_spacing # 0.1 = 10%
self._spacing_pixels = font_size * letter_spacing
# 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
self._scroll_active = True
# Scroll mode does not support eliding or multiline wrapping
if self._scroll:
self._elide = False
self._wrap_text = False
# Cached data
self._cached_text: str | None = None
self._cached_wrapped_lines: list[str] = []
self._cached_line_sizes: list[rl.Vector2] = []
self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
self._cached_total_height: float | None = None
self._cached_width: int = -1
# If max_width is set, initialize rect size for Scroller support
if max_width is not None:
self._rect.width = max_width
self._rect.height = self.get_content_height(max_width)
def set_text(self, text: str | Callable[[], str]):
"""Update the text content."""
old_text = self.text
new_text = str(_resolve_value(text))
self._text = text
if old_text != new_text:
self.reset_scroll()
self._cached_text = None
# No need to update cache here, will be done on next render if needed
@property
def text(self) -> str:
"""Get the current text content."""
return str(_resolve_value(self._text))
def set_text_color(self, color: rl.Color):
"""Update the text color."""
self._text_color = color
def set_color(self, color: rl.Color):
"""Update the text color (alias for set_text_color)."""
self.set_text_color(color)
def set_font_size(self, size: int):
"""Update the font size."""
if self._font_size != size:
self._font_size = size
self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
self._cached_text = None # Invalidate cache
def set_letter_spacing(self, letter_spacing: float):
"""Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
if self._letter_spacing != letter_spacing:
self._letter_spacing = letter_spacing
self._spacing_pixels = self._font_size * letter_spacing
self._cached_text = None # Invalidate cache
def set_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_line_height(self, line_height: float):
"""Update line height (multiplier, e.g., 1.0 = default)."""
new_line_height = line_height * 0.9
if self._line_height != new_line_height:
self._line_height = new_line_height
self._cached_text = None
def reset_scroll(self):
"""Reset scroll state to initial position."""
self._scroll_offset = 0
self._scroll_pause_t = None
self._scroll_state = ScrollState.STARTING
def set_scroll_active(self, active: bool) -> None:
if self._scroll_active == active:
return
self._scroll_active = active
if not active:
self.reset_scroll()
def set_max_width(self, max_width: int | None):
"""Set the maximum width constraint for wrapping/eliding."""
if self._max_width != max_width:
self.reset_scroll()
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
if self._cached_text is not None and (self._cached_text != text or self._cached_width != available_width):
self.reset_scroll()
self._cached_text = text
self._cached_width = available_width
# Determine wrapping width
content_width = available_width - (self._text_padding * 2)
if content_width <= 0:
content_width = 1
# Wrap text if enabled
if self._wrap_text:
self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
else:
# Split by newlines but don't wrap
self._cached_wrapped_lines = text.split('\n') if text else [""]
# Elide lines if needed (for width constraint)
self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
if self._scroll:
self._cached_wrapped_lines = self._cached_wrapped_lines[:1] # Only first line for scrolling
# Process each line: measure and find emojis
self._cached_line_sizes = []
self._cached_line_emojis = []
for line in self._cached_wrapped_lines:
emojis = find_emoji(line)
self._cached_line_emojis.append(emojis)
# Empty lines should still have height (use font size as line height)
if not line:
size = rl.Vector2(0, self._font_size * FONT_SCALE)
else:
size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
# This is the only line
if self._scroll:
self._needs_scroll = size.x > content_width
self._cached_line_sizes.append(size)
# Calculate total height
# Each line contributes its measured height * line_height (matching Label's behavior)
# This includes spacing to the next line
if self._cached_line_sizes:
# Match the rendering logic: first line doesn't get line_height scaling
total_height = 0.0
for idx, size in enumerate(self._cached_line_sizes):
if idx == 0:
total_height += size.y
else:
total_height += size.y * self._line_height
self._cached_total_height = total_height
else:
self._cached_total_height = 0.0
def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
"""Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
if not self._elide and not force:
return line
text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
if text_size.x <= max_width and not force:
return line
ellipsis = "..."
# If force=True and line fits, just append ellipsis without truncating
if force and text_size.x <= max_width:
ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
if text_size.x + ellipsis_size.x <= max_width:
return line + ellipsis
# If line + ellipsis doesn't fit, need to truncate
# Fall through to binary search below
left, right = 0, len(line)
while left < right:
mid = (left + right) // 2
candidate = line[:mid] + ellipsis
candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
if candidate_size.x <= max_width:
left = mid + 1
else:
right = mid
return line[:left - 1] + ellipsis if left > 0 else ellipsis
def get_content_height(self, max_width: int) -> float:
"""
Returns the height needed for text at given max_width.
Similar to HtmlRenderer.get_total_height().
"""
# Use max_width if provided, otherwise use self._max_width or a default
width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
self._update_text_cache(width)
if self._cached_total_height is not None:
return self._cached_total_height
return 0.0
def _render(self, _):
"""Render the label."""
if self._rect.width <= 0 or self._rect.height <= 0:
return
# Determine available width
available_width = self._rect.width
if self._max_width is not None:
available_width = min(available_width, self._max_width)
# Update text cache
self._update_text_cache(int(available_width))
if not self._cached_wrapped_lines:
return
# Calculate which lines fit in the available height
visible_lines: list[str] = []
visible_sizes: list[rl.Vector2] = []
visible_emojis: list[list[tuple[int, int, str]]] = []
current_height = 0.0
broke_early = False
for line, size, emojis in zip(
self._cached_wrapped_lines,
self._cached_line_sizes,
self._cached_line_emojis,
strict=True):
# Calculate height needed for this line
# Each line contributes its height * line_height (matching Label's behavior)
line_height_needed = size.y * self._line_height
# Check if this line fits
if current_height + line_height_needed > self._rect.height:
# This line doesn't fit
if len(visible_lines) == 0:
# First line doesn't fit by height - still show it (will be clipped by scissor if needed)
# Continue to add this line below
pass
else:
# We have visible lines and this one doesn't fit - mark that we broke early
broke_early = True
break
visible_lines.append(line)
visible_sizes.append(size)
visible_emojis.append(emojis)
current_height += line_height_needed
# If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
if broke_early and len(visible_lines) > 0 and self._elide:
content_width = int(available_width - (self._text_padding * 2))
if content_width <= 0:
content_width = 1
last_line_idx = len(visible_lines) - 1
last_line = visible_lines[last_line_idx]
# Force elide the last line to show "..." even if it fits in width (to indicate more content)
elided = self._elide_line(last_line, content_width, force=True)
visible_lines[last_line_idx] = elided
visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
if not visible_lines:
return
# Calculate total visible text block height
# First line is not changed by line_height scaling
total_visible_height = 0.0
for idx, size in enumerate(visible_sizes):
if idx == 0:
total_visible_height += size.y
else:
total_visible_height += size.y * self._line_height
# Calculate vertical alignment offset
if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
start_y = self._rect.y
elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
start_y = self._rect.y + self._rect.height - total_visible_height
else: # TEXT_ALIGN_MIDDLE
start_y = self._rect.y + (self._rect.height - total_visible_height) / 2
# Only scissor when we know there is a single scrolling line
# Pad a little since descenders like g or j may overflow below rect from font_scale
if self._needs_scroll:
scissor_x = int(self._rect.x + self._text_padding)
scissor_w = int(max(1, self._rect.width - self._text_padding * 2))
rl.begin_scissor_mode(scissor_x, int(self._rect.y - self._font_size / 2), scissor_w, int(self._rect.height + self._font_size))
# Render each line
current_y = start_y
for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
if self._needs_scroll and self._scroll_active:
content_width = max(1.0, available_width - self._text_padding * 2)
max_scroll = max(0.0, size.x - content_width)
speed = 0.8 / 60. * gui_app.target_fps
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 = max(-max_scroll, self._scroll_offset - speed)
if self._scroll_offset <= -max_scroll:
self._scroll_state = ScrollState.ENDING
self._scroll_pause_t = None
elif self._scroll_state == ScrollState.ENDING:
if self._scroll_pause_t is None:
self._scroll_pause_t = rl.get_time() + 1.5
if rl.get_time() >= self._scroll_pause_t:
self._scroll_offset = 0
self._scroll_state = ScrollState.STARTING
self._scroll_pause_t = None
else:
self.reset_scroll()
self._render_line(line, size, emojis, current_y)
# Move to next line (if not last line)
if idx < len(visible_lines) - 1:
# Use current line's height * line_height for spacing to next line
current_y += size.y * self._line_height
if self._needs_scroll:
# draw black fade on left and right
fade_width = 20
left_edge = self._rect.x + self._text_padding
right_edge = self._rect.x + self._rect.width - self._text_padding
content_width = max(1.0, available_width - self._text_padding * 2)
max_scroll = max(0.0, visible_sizes[0].x - content_width)
if self._scroll_offset > -max_scroll:
rl.draw_rectangle_gradient_h(
int(right_edge - fade_width), int(self._rect.y),
fade_width, int(self._rect.height), rl.BLANK, rl.BLACK,
)
if self._scroll_active and self._scroll_offset < 0:
rl.draw_rectangle_gradient_h(
int(left_edge), int(self._rect.y), fade_width, int(self._rect.height), rl.BLACK, rl.BLANK,
)
rl.end_scissor_mode()
def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
# Calculate horizontal position
if self._needs_scroll:
line_x = self._rect.x + self._text_padding
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
line_x = self._rect.x + self._text_padding
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
line_x = self._rect.x + (self._rect.width - size.x) / 2
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
line_x = self._rect.x + self._rect.width - size.x - self._text_padding
else:
line_x = self._rect.x + self._text_padding
line_x += self._scroll_offset + x_offset
# 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)
if tex is not None:
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)

View File

@@ -0,0 +1,518 @@
import os
import pyray as rl
from collections.abc import Callable
from abc import ABC
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
from iqpilot.system.ui.widgets.label import gui_label, UnifiedLabel
from iqpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
ITEM_BASE_WIDTH = 600
ITEM_BASE_HEIGHT = 170
ITEM_PADDING = 20
ITEM_TEXT_FONT_SIZE = 50
ITEM_TEXT_COLOR = rl.WHITE
ITEM_TEXT_VALUE_COLOR = rl.Color(170, 170, 170, 255)
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
ITEM_DESC_FONT_SIZE = 40
ITEM_DESC_V_OFFSET = 140
RIGHT_ITEM_PADDING = 20
ICON_SIZE = 80
BUTTON_WIDTH = 250
BUTTON_HEIGHT = 100
BUTTON_BORDER_RADIUS = 50
BUTTON_FONT_SIZE = 35
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
TEXT_PADDING = 20
def _resolve_value(value, default=""):
if callable(value):
return value()
return value if value is not None else default
# Abstract base class for right-side items
class ItemAction(Widget, ABC):
def __init__(self, width: int = BUTTON_HEIGHT, enabled: bool | Callable[[], bool] = True):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, width, 0))
self._enabled_source = enabled
def get_width_hint(self) -> float:
# Return's action ideal width, 0 means use full width
return self._rect.width
def set_enabled(self, enabled: bool | Callable[[], bool]):
self._enabled_source = enabled
@property
def enabled(self):
return _resolve_value(self._enabled_source, False)
class ToggleAction(ItemAction):
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True,
callback: Callable[[bool], None] | None = None):
super().__init__(width, enabled)
self.toggle = Toggle(initial_state=initial_state, callback=callback)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.toggle.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle) -> bool:
self.toggle.set_enabled(self.enabled)
clicked = self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self._rect.width, TOGGLE_HEIGHT))
return bool(clicked)
def set_state(self, state: bool):
self.toggle.set_state(state)
def get_state(self) -> bool:
return self.toggle.get_state()
class ButtonAction(ItemAction):
def __init__(self, text: str | Callable[[], str], width: int = BUTTON_WIDTH, enabled: bool | Callable[[], bool] = True):
super().__init__(width, enabled)
self._text_source = text
self._value_source: str | Callable[[], str] | None = None
self._pressed = False
self._font = gui_app.font(FontWeight.NORMAL)
self._value_label = UnifiedLabel("", ITEM_TEXT_FONT_SIZE, text_color=ITEM_TEXT_VALUE_COLOR,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
wrap_text=False, scroll=True)
def pressed():
self._pressed = True
self._button = Button(
self.text,
font_size=BUTTON_FONT_SIZE,
font_weight=BUTTON_FONT_WEIGHT,
button_style=ButtonStyle.LIST_ACTION,
border_radius=BUTTON_BORDER_RADIUS,
click_callback=pressed,
text_padding=0,
)
self.set_enabled(enabled)
def get_width_hint(self) -> float:
value_text = self.value
if value_text:
text_width = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x
return text_width + BUTTON_WIDTH + TEXT_PADDING
else:
return BUTTON_WIDTH
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self._button.set_touch_valid_callback(touch_callback)
def set_text(self, text: str | Callable[[], str]):
self._text_source = text
def set_value(self, value: str | Callable[[], str]):
self._value_source = value
@property
def text(self):
return _resolve_value(self._text_source, tr("Error"))
@property
def value(self):
return _resolve_value(self._value_source, "")
def _render(self, rect: rl.Rectangle) -> bool:
self._button.set_text(self.text)
self._button.set_enabled(_resolve_value(self.enabled))
button_rect = rl.Rectangle(rect.x + rect.width - BUTTON_WIDTH, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT)
self._button.render(button_rect)
value_text = self.value
if value_text:
value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height)
if measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x > value_rect.width:
self._value_label.set_text(value_text)
self._value_label.set_text_color(ITEM_TEXT_VALUE_COLOR)
self._value_label.render(value_rect)
else:
gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# TODO: just use the generic Widget click callbacks everywhere, no returning from render
pressed = self._pressed
self._pressed = False
return pressed
class TextAction(ItemAction):
def __init__(self, text: str | Callable[[], str], color: rl.Color = ITEM_TEXT_COLOR, enabled: bool | Callable[[], bool] = True):
self._text_source = text
self.color = color
self._font = gui_app.font(FontWeight.NORMAL)
initial_text = _resolve_value(text, "")
text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x
self._scroll_label = UnifiedLabel(initial_text, ITEM_TEXT_FONT_SIZE, text_color=color,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
wrap_text=False, scroll=True)
super().__init__(int(text_width + TEXT_PADDING), enabled)
@property
def text(self):
return _resolve_value(self._text_source, tr("Error"))
def get_width_hint(self) -> float:
text_width = measure_text_cached(self._font, self.text, ITEM_TEXT_FONT_SIZE).x
return text_width + TEXT_PADDING
def _render(self, rect: rl.Rectangle) -> bool:
text = self.text
if measure_text_cached(self._font, text, ITEM_TEXT_FONT_SIZE).x > rect.width:
self._scroll_label.set_text(text)
self._scroll_label.set_text_color(self.color)
self._scroll_label.render(rect)
else:
gui_label(self._rect, text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color,
font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
return False
def set_text(self, text: str | Callable[[], str]):
self._text_source = text
class DualButtonAction(ItemAction):
def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None,
right_callback: Callable | None = None, enabled: bool | Callable[[], bool] = True):
super().__init__(width=0, enabled=enabled) # Width 0 means use full width
self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0)
self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self.left_button.set_touch_valid_callback(touch_callback)
self.right_button.set_touch_valid_callback(touch_callback)
def _render(self, rect: rl.Rectangle):
button_spacing = 30
button_height = 120
button_width = (rect.width - button_spacing) / 2
button_y = rect.y + (rect.height - button_height) / 2
left_rect = rl.Rectangle(rect.x, button_y, button_width, button_height)
right_rect = rl.Rectangle(rect.x + button_width + button_spacing, button_y, button_width, button_height)
# expand one to full width if other is not visible
if not self.left_button.is_visible:
right_rect.x = rect.x
right_rect.width = rect.width
elif not self.right_button.is_visible:
left_rect.width = rect.width
# Render buttons
self.left_button.render(left_rect)
self.right_button.render(right_rect)
class MultipleButtonAction(ItemAction):
def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True):
super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=enabled)
self.buttons = buttons
self.button_width = button_width
self.selected_button = selected_index
self.callback = callback
self._font = gui_app.font(FontWeight.MEDIUM)
self._enabled_buttons_source: list[bool] | Callable[[int], bool] | None = None
def set_enabled_buttons(self, enabled_buttons: list[bool] | Callable[[int], bool] | None):
self._enabled_buttons_source = enabled_buttons
def _is_button_enabled(self, index: int) -> bool:
if not self.enabled:
return False
if self._enabled_buttons_source is None:
return True
if callable(self._enabled_buttons_source):
return bool(self._enabled_buttons_source(index))
if 0 <= index < len(self._enabled_buttons_source):
return bool(self._enabled_buttons_source[index])
return False
def set_selected_button(self, index: int):
if 0 <= index < len(self.buttons):
self.selected_button = index
def get_selected_button(self) -> int:
return self.selected_button
def _render(self, rect: rl.Rectangle):
spacing = RIGHT_ITEM_PADDING
button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2
button_count = len(self.buttons)
button_width = (rect.width - spacing * (button_count - 1)) / button_count
for i, _text in enumerate(self.buttons):
button_x = rect.x + i * (button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, button_width, BUTTON_HEIGHT)
button_enabled = self._is_button_enabled(i)
# Check button state
mouse_pos = rl.get_mouse_position()
is_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and button_enabled and self.is_pressed
is_selected = i == self.selected_button
# Button colors
if is_selected:
bg_color = rl.Color(51, 171, 76, 255) # Green
elif is_pressed:
bg_color = rl.Color(74, 74, 74, 255) # Dark gray
else:
bg_color = rl.Color(57, 57, 57, 255) # Gray
if not button_enabled:
bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim
# Draw button
rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color)
# Draw text
text = _resolve_value(_text, "")
font_size = 40
max_text_width = max(1, button_width - TEXT_PADDING * 2)
text_size = measure_text_cached(self._font, text, font_size)
while text_size.x > max_text_width and font_size > 30:
font_size -= 2
text_size = measure_text_cached(self._font, text, font_size)
text_x = button_x + (button_width - text_size.x) / 2
text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2
text_color = rl.Color(228, 228, 228, 255) if button_enabled else rl.Color(150, 150, 150, 255)
rl.begin_scissor_mode(int(button_rect.x), int(button_rect.y), int(button_rect.width), int(button_rect.height))
rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), font_size, 0, text_color)
rl.end_scissor_mode()
def _handle_mouse_release(self, mouse_pos: MousePos):
spacing = RIGHT_ITEM_PADDING
button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2
button_count = len(self.buttons)
button_width = (self._rect.width - spacing * (button_count - 1)) / button_count
for i, _ in enumerate(self.buttons):
button_x = self._rect.x + i * (button_width + spacing)
button_rect = rl.Rectangle(button_x, button_y, button_width, BUTTON_HEIGHT)
if rl.check_collision_point_rec(mouse_pos, button_rect):
if not self._is_button_enabled(i):
continue
self.selected_button = i
if self.callback:
self.callback(i)
class ListItem(Widget):
def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None,
description_visible: bool = False, callback: Callable | None = None,
action_item: ItemAction | None = None):
super().__init__()
self._title = title
self.set_icon(icon)
self._description = description
self.description_visible = description_visible
self.callback = callback
self.description_opened_callback: Callable | None = None
self.action_item = action_item
self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT))
self._font = gui_app.font(FontWeight.NORMAL)
self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE},
text_color=ITEM_DESC_TEXT_COLOR)
self._parse_description(self.description)
# Cached properties for performance
self._prev_description: str | None = self.description
def show_event(self):
self._set_description_visible(False)
def set_description_opened_callback(self, callback: Callable) -> None:
self.description_opened_callback = callback
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
if self.action_item:
self.action_item.set_touch_valid_callback(touch_callback)
def set_parent_rect(self, parent_rect: rl.Rectangle):
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self.is_visible:
return
# Check not in action rect
if self.action_item:
action_rect = self.get_right_item_rect(self._rect)
if rl.check_collision_point_rec(mouse_pos, action_rect):
# Click was on right item, don't toggle description
return
self._set_description_visible(not self.description_visible)
def _set_description_visible(self, visible: bool):
if self.description and self.description_visible != visible:
self.description_visible = visible
# do callback first in case receiver changes description
if self.description_visible and self.description_opened_callback is not None:
self.description_opened_callback()
# Call _update_state to catch any description changes
self._update_state()
content_width = int(self._rect.width - ITEM_PADDING * 2)
self._rect.height = self.get_item_height(self._font, content_width)
def _update_state(self):
# Detect changes if description is callback
new_description = self.description
if new_description != self._prev_description:
self._parse_description(new_description)
def _render(self, _):
if not self.is_visible:
return
# Don't draw items that are not in parent's viewport
if ((self._rect.y + self.rect.height) <= self._parent_rect.y or
self._rect.y >= (self._parent_rect.y + self._parent_rect.height)):
return
content_x = self._rect.x + ITEM_PADDING
text_x = content_x
# Only draw title and icon for items that have them
if self.title:
# Draw icon if present
if self.icon:
rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) // 2), rl.WHITE)
text_x += ICON_SIZE + ITEM_PADDING
# Draw main text
text_size = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE)
item_y = self._rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
# Draw description if visible
if self.description_visible:
content_width = int(self._rect.width - ITEM_PADDING * 2)
description_height = self._html_renderer.get_total_height(content_width)
description_rect = rl.Rectangle(
self._rect.x + ITEM_PADDING,
self._rect.y + ITEM_DESC_V_OFFSET,
content_width,
description_height
)
self._html_renderer.render(description_rect)
# Draw right item if present
if self.action_item:
right_rect = self.get_right_item_rect(self._rect)
right_rect.y = self._rect.y
if self.action_item.render(right_rect) and self.action_item.enabled:
# Right item was clicked/activated
if self.callback:
self.callback()
def set_icon(self, icon: str | None):
self.icon = icon
self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None
def set_description(self, description: str | Callable[[], str] | None):
self._description = description
def _parse_description(self, new_desc):
self._html_renderer.parse_html_content(new_desc)
self._prev_description = new_desc
@property
def title(self):
return _resolve_value(self._title, "")
@property
def description(self):
return _resolve_value(self._description, "")
def get_item_height(self, font: rl.Font, max_width: int) -> float:
if not self.is_visible:
return 0
height = float(ITEM_BASE_HEIGHT)
if self.description_visible:
description_height = self._html_renderer.get_total_height(max_width)
height += description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_PADDING
return height
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
if not self.action_item:
return rl.Rectangle(0, 0, 0, 0)
right_width = self.action_item.get_width_hint()
if right_width == 0: # Full width action (like DualButtonAction)
return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y,
item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT)
# Clip width to available space, never overlapping this Item's title
content_width = item_rect.width - (ITEM_PADDING * 2)
title_width = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE).x
right_width = min(content_width - title_width, right_width)
right_x = item_rect.x + item_rect.width - right_width
right_y = item_rect.y
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
# Factory functions
def simple_item(title: str | Callable[[], str], callback: Callable | None = None) -> ListItem:
return ListItem(title=title, callback=callback)
def toggle_item(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem:
action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback)
return ListItem(title=title, description=description, action_item=action, icon=icon)
def button_item(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = ButtonAction(text=button_text, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
def text_item(title: str | Callable[[], str], value: str | Callable[[], str], description: str | Callable[[], str] | None = None,
callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, enabled=enabled)
return ListItem(title=title, description=description, action_item=action, callback=callback)
def dual_button_item(left_text: str | Callable[[], str], right_text: str | Callable[[], str],
left_callback: Callable | None = None, right_callback: Callable | None = None,
description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem:
action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled)
return ListItem(title="", description=description, action_item=action)
def multiple_button_item(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], selected_index: int,
button_width: int = BUTTON_WIDTH, callback: Callable | None = None, icon: str = ""):
action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback)
return ListItem(title=title, description=description, icon=icon, action_item=action)

View File

@@ -0,0 +1,418 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from enum import IntEnum
import pyray as rl
import numpy as np
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from iqpilot.system.ui.lib.raylib_compat import draw_circle_gradient
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
CHAR_FONT_SIZE = 42
CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2
SELECTED_CHAR_FONT_SIZE = 128
CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this
NUMBER_LAYER_SWITCH_FONT_SIZE = 24
KEYBOARD_COLUMN_PADDING = 33
KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in
KEY_TOUCH_AREA_OFFSET = 10 # px
KEY_DRAG_HYSTERESIS = 5 # px
KEY_MIN_ANIMATION_TIME = 0.075 # s
DEBUG = False
ANIMATION_SCALE = 0.65
def zip_repeat(a, b):
la, lb = len(a), len(b)
for i in range(max(la, lb)):
yield (a[i] if i < la else a[-1],
b[i] if i < lb else b[-1])
def fast_euclidean_distance(dx, dy):
# https://en.wikibooks.org/wiki/Algorithms/Distance_approximations
max_d, min_d = abs(dx), abs(dy)
if max_d < min_d:
max_d, min_d = min_d, max_d
return 0.941246 * max_d + 0.41 * min_d
class Key(Widget):
def __init__(self, char: str, font_weight: FontWeight = FontWeight.SEMI_BOLD):
super().__init__()
self.char = char
self._font = gui_app.font(font_weight)
self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._color = rl.Color(255, 255, 255, 255)
self._position_initialized = False
self.original_position = rl.Vector2(0, 0)
def set_position(self, x: float, y: float, smooth: bool = True):
# Smooth keys within parent rect
base_y = self._parent_rect.y if self._parent_rect else 0.0
local_y = y - base_y
if not self._position_initialized:
self._x_filter.x = x
self._y_filter.x = local_y
# keep track of original position so dragging around feels consistent. also move touch area down a bit
self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET)
self._position_initialized = True
if not smooth:
self._x_filter.x = x
self._y_filter.x = local_y
self._rect.x = self._x_filter.update(x)
self._rect.y = base_y + self._y_filter.update(local_y)
def set_alpha(self, alpha: float):
self._alpha_filter.update(alpha)
def get_position(self) -> tuple[float, float]:
return self._rect.x, self._rect.y
def _update_state(self):
self._color.a = min(int(255 * self._alpha_filter.x), 255)
def _render(self, _):
# center char at rect position
text_size = measure_text_cached(self._font, self.char, self._get_font_size())
x = self._rect.x + self._rect.width / 2 - text_size.x / 2
y = self._rect.y + self._rect.height / 2 - text_size.y / 2
rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color)
if DEBUG:
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
def set_font_size(self, size: float):
self._size_filter.update(size)
def _get_font_size(self) -> int:
return int(round(self._size_filter.x))
class SmallKey(Key):
def __init__(self, chars: str):
super().__init__(chars, FontWeight.BOLD)
self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
def set_font_size(self, size: float):
self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE))
class IconKey(Key):
def __init__(self, icon: str, vertical_align: str = "center", char: str = "", icon_size: tuple[int, int] = (38, 38)):
super().__init__(char)
self._icon_size = icon_size
self._icon = gui_app.texture(icon, *icon_size)
self._vertical_align = vertical_align
def set_icon(self, icon: str, icon_size: tuple[int, int] | None = None):
size = icon_size if icon_size is not None else self._icon_size
self._icon = gui_app.texture(icon, *size)
def _render(self, _):
scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5])
if self._vertical_align == "center":
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2,
self._rect.y + (self._rect.height - self._icon.height * scale) / 2,
self._icon.width * scale, self._icon.height * scale)
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
elif self._vertical_align == "bottom":
dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y,
self._icon.width * scale, self._icon.height * scale)
src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
if DEBUG:
rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
class CapsState(IntEnum):
LOWER = 0
UPPER = 1
LOCK = 2
class MiciKeyboard(Widget):
def __init__(self, auto_return_to_letters: str = ""):
super().__init__()
self._auto_return_to_letters = auto_return_to_letters
lower_chars = [
"qwertyuiop",
"asdfghjkl",
"zxcvbnm",
]
upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars]
special_chars = [
"1234567890",
"-/:;()$&@\"",
"~.,?!'#%",
]
super_special_chars = [
"1234567890",
"`[]{}^*+=_",
"\\|<>¥€£•",
]
self._lower_keys = [[Key(char) for char in row] for row in lower_chars]
self._upper_keys = [[Key(char) for char in row] for row in upper_chars]
self._special_keys = [[Key(char) for char in row] for row in special_chars]
self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
# control keys
self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom", icon_size=(43, 14))
self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png", icon_size=(38, 33))
# these two are in different places on some layouts
self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123")
self._abc_key = SmallKey("abc")
self._super_special_key = SmallKey("#+=")
# insert control keys
for keys in (self._lower_keys, self._upper_keys):
keys[2].insert(0, self._caps_key)
keys[2].append(self._123_key)
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
keys[1].append(self._space_key)
for keys in (self._special_keys, self._super_special_keys):
keys[2].append(self._abc_key)
self._special_keys[2].insert(0, self._super_special_key)
self._super_special_keys[2].insert(0, self._123_key2)
# set initial keys
self._current_keys: list[list[Key]] = []
self._set_keys(self._lower_keys)
self._caps_state = CapsState.LOWER
self._initialized = False
self._load_images()
self._closest_key: tuple[Key | None, float] = None, float('inf')
self._selected_key_t: float | None = None # time key was initially selected
self._unselect_key_t: float | None = None # time to unselect key after release
self._dragging_on_keyboard = False
self._text: str = ""
self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
self._selected_key_filter = FirstOrderFilter(0.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
def get_candidate_character(self) -> str:
# return str of character about to be added to text
key = self._closest_key[0]
return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else ""
def get_keyboard_height(self) -> int:
return int(self._txt_bg.height)
def _load_images(self):
self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False)
def _set_keys(self, keys: list[list[Key]]):
# inherit previous keys' positions to fix switching animation
for current_row, row in zip(self._current_keys, keys, strict=False):
# not all layouts have the same number of keys
for current_key, key in zip_repeat(current_row, row):
current_pos = current_key.get_position()
# Anchor to the CURRENT keyboard rect before snapping. The non-visible layers are first laid
# out while the host dialog is still sliding in (rect.y high), so their _parent_rect is stale;
# snapping the position filter against that stale anchor then re-anchoring in _lay_out_keys
# made the keys jump into place on the first switch to that layer (shift / 123).
key.set_parent_rect(self._rect)
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", icon_size=(38, 33))
else:
if self._caps_state == CapsState.LOWER:
self._caps_state = CapsState.UPPER
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png", icon_size=(38, 33))
elif self._caps_state == CapsState.UPPER:
self._caps_state = CapsState.LOCK
self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png", icon_size=(39, 38))
else:
self._set_uppercase(False)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._closest_key[0] is not None:
if self._closest_key[0] == self._caps_key:
self._set_uppercase(True)
elif self._closest_key[0] in (self._123_key, self._123_key2):
self._set_keys(self._special_keys)
elif self._closest_key[0] == self._abc_key:
self._set_uppercase(False)
elif self._closest_key[0] == self._super_special_key:
self._set_keys(self._super_special_keys)
else:
self._text += self._closest_key[0].char
# Reset caps state
if self._caps_state == CapsState.UPPER:
self._set_uppercase(False)
if self._closest_key[0].char in self._auto_return_to_letters and self._current_keys in (self._special_keys, self._super_special_keys):
self._set_uppercase(False)
# ensure minimum selected animation time
key_selected_dt = rl.get_time() - (self._selected_key_t or 0)
cur_t = rl.get_time()
self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t
def backspace(self):
if self._text:
self._text = self._text[:-1]
def space(self):
self._text += ' '
def _update_state(self):
# update selected key filter
self._selected_key_filter.update(self._closest_key[0] is not None)
# unselect key after animation plays
if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t:
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
# Anchor the hit-test grid to the *resting* layout position (before the pop-up animation
# offsets below). Recompute it every frame so it tracks the keyboard's real rect: the host
# dialog slides in from the bottom, so the very first render happens with rect.y far down —
# locking original_position once there put the whole grid below every key and made every
# tap resolve to the top row.
key.original_position = rl.Vector2(key_x, key_y + KEY_TOUCH_AREA_OFFSET)
if self._closest_key[0] is None:
key.set_alpha(1.0)
key.set_font_size(CHAR_FONT_SIZE)
elif key == self._closest_key[0]:
# push key up with a max and inward so user can see key easier
key_y = max(key_y - 120, 40)
key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100])
key.set_alpha(1.0)
key.set_font_size(SELECTED_CHAR_FONT_SIZE)
# draw black circle behind selected key
circle_alpha = int(self._selected_key_filter.x * 225)
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, circle_alpha), rl.BLANK)
else:
# move other keys away from selected key a bit
dx = key.original_position.x - self._closest_key[0].original_position.x
dy = key.original_position.y - self._closest_key[0].original_position.y
distance_from_selected_key = fast_euclidean_distance(dx, dy)
inv = 1 / (distance_from_selected_key or 1.0)
ux = dx * inv
uy = dy * inv
# NOTE: hardcode to 20 to get entire keyboard to move
push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0])
key_x += ux * push_pixels
key_y += uy * push_pixels
# TODO: slow enough to use an approximation or nah? also caching might work
font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE])
key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35])
key.set_alpha(key_alpha)
key.set_font_size(font_size)
# TODO: I like the push amount, so we should clip the pos inside the keyboard rect
key.set_parent_rect(self._rect)
key.set_position(key_x, key_y)
def _render(self, _):
# draw bg
bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2
bg_y = self._rect.y + self._rect.height - self._txt_bg.height
scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0)
src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height)
dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y,
self._txt_bg.width * scale, self._txt_bg.height)
rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE)
# draw keys
if not self._initialized:
for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
self._lay_out_keys(bg_x, bg_y, keys)
self._initialized = True
self._lay_out_keys(bg_x, bg_y, self._current_keys)
for row in self._current_keys:
for key in row:
key.render()

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
import abc
import pyray as rl
from collections.abc import Callable
from iqpilot.system.ui.widgets import Widget
from iqpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from iqpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent
from iqpilot.selfdrive.ui.ui_state import device
SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
NAV_BAR_MARGIN = 6
NAV_BAR_WIDTH = 205
NAV_BAR_HEIGHT = 8
DISMISS_PUSH_OFFSET = NAV_BAR_MARGIN + NAV_BAR_HEIGHT + 50 # px extra to push down when dismissing
DISMISS_ANIMATION_RC = 0.2 # slightly slower for non-user triggered dismiss animation
class NavBar(Widget):
FADE_AFTER_SECONDS = 2.0
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT))
self._alpha = 1.0
self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._fade_time = 0.0
def set_alpha(self, alpha: float) -> None:
self._alpha = alpha
self._fade_time = rl.get_time()
def show_event(self):
super().show_event()
self._alpha = 1.0
self._alpha_filter.x = 1.0
self._fade_time = rl.get_time()
def _render(self, _):
if rl.get_time() - self._fade_time > self.FADE_AFTER_SECONDS:
self._alpha = 0.0
alpha = self._alpha_filter.update(self._alpha)
# white bar with black border
rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha)))
rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha)))
class NavWidget(Widget, abc.ABC):
"""
A full screen widget that supports back navigation by swiping down from the top.
"""
BACK_TOUCH_AREA_PERCENTAGE = 0.65
def __init__(self):
super().__init__()
# State
self._drag_start_pos: MousePos | None = None # cleared after certain amount of horizontal movement
self._dragging_down = False # swiped down enough to trigger dismissing on release
self._playing_dismiss_animation = False # released and animating away
self._y_pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
self._back_callback: Callable[[], None] | None = None # persistent callback for user-initiated back navigation
self._dismiss_callback: Callable[[], None] | None = None # transient callback for programmatic dismiss
# TODO: add this functionality to push_widget
self._shown_callback: Callable[[], None] | None = None # transient callback fired after show animation completes
# TODO: move this state into NavBar
self._nav_bar = self._child(NavBar())
self._nav_bar_show_time = 0.0
self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
def _back_enabled(self) -> bool:
# Children can override this to block swipe away, like when not at
# the top of a vertical scroll panel to prevent erroneous swipes
return True
def covers_below(self) -> bool:
# Skip drawing the widgets underneath ONLY when fully settled at the top (idle perf). During
# a swipe/dismiss/slide-in return False so the page below renders and shows through behind the
# moving page, like stock (instead of black).
return (self._rect.y < 1.0
and self._drag_start_pos is None
and not self._playing_dismiss_animation
and abs(self._y_pos_filter.velocity.x) < 0.5)
def settle_to_top(self) -> None:
# Snap to the resting (fully-shown) position and cancel any in-flight slide-in animation AND
# any pending swipe/dismiss state. Called when this page is covered or revealed: while covered
# it isn't rendered, so the "if not enabled: clear drag" cleanup in _update_state never runs and
# a drag-start captured just before it was covered (e.g. the press that opened the sub-page)
# would otherwise stick and make the page track the finger the instant it's revealed.
self._y_pos_filter.x = 0.0
self._y_pos_filter.velocity.x = 0.0
self._drag_start_pos = None
self._dragging_down = False
self._playing_dismiss_animation = False
self.set_position(self._rect.x, 0.0)
def set_back_callback(self, callback: Callable[[], None]) -> None:
self._back_callback = callback
def set_shown_callback(self, callback: Callable[[], None] | None) -> None:
self._shown_callback = callback
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
# Don't let touch events change filter state during dismiss animation
if self._playing_dismiss_animation:
return
if mouse_event.left_pressed:
# user is able to swipe away if starting near top of screen
self._y_pos_filter.update_alpha(0.04)
in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
if in_dismiss_area and self._back_enabled():
self._drag_start_pos = mouse_event.pos
elif mouse_event.left_down:
if self._drag_start_pos is not None:
# block swiping away if too much horizontal or upward movement
# block (lock-in) threshold is higher than start dismissing
horizontal_movement = abs(mouse_event.pos.x - self._drag_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
upward_movement = mouse_event.pos.y - self._drag_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
if not (horizontal_movement or upward_movement):
# no blocking movement, check if we should start dismissing
if mouse_event.pos.y - self._drag_start_pos.y > START_DISMISSING_THRESHOLD:
self._dragging_down = True
else:
if not self._dragging_down:
self._drag_start_pos = None
elif mouse_event.left_released:
# reset rc for either slide up or down animation
self._y_pos_filter.update_alpha(0.1)
# if far enough, trigger back navigation callback
if self._drag_start_pos is not None:
if mouse_event.pos.y - self._drag_start_pos.y > SWIPE_AWAY_THRESHOLD:
self._playing_dismiss_animation = True
self._drag_start_pos = None
self._dragging_down = False
def _update_state(self):
super()._update_state()
new_y = 0.0
if self._dragging_down:
self._nav_bar.set_alpha(1.0)
# FIXME: disabling this widget on new push_widget still causes this widget to track mouse events without mouse down
if not self.enabled:
self._drag_start_pos = None
if self._drag_start_pos is not None:
last_mouse_event = gui_app.last_mouse_event
# push entire widget as user drags it away
new_y = max(last_mouse_event.pos.y - self._drag_start_pos.y, 0)
if new_y < SWIPE_AWAY_THRESHOLD:
new_y /= 2 # resistance until mouse release would dismiss widget
if self._playing_dismiss_animation:
new_y = self._rect.height + DISMISS_PUSH_OFFSET
new_y = self._y_pos_filter.update(new_y)
if abs(new_y) < 1 and abs(self._y_pos_filter.velocity.x) < 0.5:
new_y = self._y_pos_filter.x = 0.0
self._y_pos_filter.velocity.x = 0.0
if self._shown_callback is not None:
self._shown_callback()
self._shown_callback = None
if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
gui_app.pop_widget()
# Only one callback should ever be fired
if self._dismiss_callback is not None:
self._dismiss_callback()
self._dismiss_callback = None
elif self._back_callback is not None:
self._back_callback()
self._playing_dismiss_animation = False
self._drag_start_pos = None
self._dragging_down = False
self.set_position(self._rect.x, new_y)
def _layout(self):
# Only paint THIS widget's own opaque background (from self._rect.y down). The area above
# self._rect.y is the part revealed while swiping down — leave it untouched so the page behind
# renders through at full brightness, matching stock. A previous full-screen dim overlay
# darkened that revealed area to a sliver until the swipe completed; it was also invisible when
# settled (covered by this same black bg), so it only ever hurt the transition.
bounce_height = 20
rl.draw_rectangle_rec(rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height + bounce_height), rl.BLACK)
def render(self, rect: rl.Rectangle | None = None) -> bool | int | None:
ret = super().render(rect)
bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
nav_bar_delayed = rl.get_time() - self._nav_bar_show_time < 0.4
# User dragging or dismissing, nav bar follows NavWidget
if self._drag_start_pos is not None or self._playing_dismiss_animation:
self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._y_pos_filter.x
# Waiting to show
elif nav_bar_delayed:
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
# Animate back to top
else:
self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
self._nav_bar.set_position(bar_x, self._nav_bar_y_filter.x)
self._nav_bar.render()
return ret
@property
def is_dismissing(self) -> bool:
return self._dragging_down or self._playing_dismiss_animation
def dismiss(self, callback: Callable[[], None] | None = None):
"""Programmatically trigger the dismiss animation. Calls pop_widget when done, then callback."""
if not self._playing_dismiss_animation:
self._playing_dismiss_animation = True
self._y_pos_filter.update_alpha(DISMISS_ANIMATION_RC)
self._dismiss_callback = callback
def show_event(self):
super().show_event()
# Reset state
self._drag_start_pos = None
self._dragging_down = False
self._playing_dismiss_animation = False
self._dismiss_callback = None
# Start NavWidget off-screen, no matter how tall it is
self._y_pos_filter.update_alpha(0.1)
self._y_pos_filter.x = gui_app.height
self._y_pos_filter.velocity.x = 0.0
self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
self._nav_bar_show_time = rl.get_time()
device.set_override_interactive_timeout(300)
def hide_event(self):
super().hide_event()
active = gui_app.get_active_widget()
if not isinstance(active, NavWidget):
device.set_override_interactive_timeout(None)

View File

@@ -0,0 +1,581 @@
from enum import IntEnum
from functools import partial
from collections.abc import Callable
from typing import cast
import threading
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from iqpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import ButtonStyle, Button
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from iqpilot.system.ui.widgets.keyboard import Keyboard
from iqpilot.system.ui.widgets.label import gui_label
from iqpilot.system.ui.widgets.scroller_tici import Scroller
from iqpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
from iqpilot.system.ui.iqwidgets.widgets.list_view import IQListItem as ListItem
from iqpilot.system.ui.iqwidgets.widgets.list_view import IQToggleAction as ToggleAction
from iqpilot.system.ui.iqwidgets.widgets.list_view import IQMultipleButtonAction as MultipleButtonAction
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
try:
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.ui_state import ui_state
except Exception:
Params = None
ui_state = None
NM_DEVICE_STATE_NEED_AUTH = 60
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 64
ITEM_HEIGHT = 160
ICON_SIZE = 50
STRENGTH_ICONS = [
"icons/wifi_strength_low.png",
"icons/wifi_strength_medium.png",
"icons/wifi_strength_high.png",
"icons/wifi_strength_full.png",
]
TEAL = rl.Color(16, 185, 169, 255)
def draw_spinner(cx: float, cy: float, radius: float, color: rl.Color = TEAL):
"""A simple rotating teal arc spinner."""
ang = (rl.get_time() * 280) % 360
inner = radius * 0.72
rl.draw_ring(rl.Vector2(cx, cy), inner, radius, 0, 360, 48, rl.Color(255, 255, 255, 26))
rl.draw_ring(rl.Vector2(cx, cy), inner, radius, ang, ang + 100, 32, color)
class PanelType(IntEnum):
WIFI = 0
ADVANCED = 1
ESIM = 2
class UIState(IntEnum):
IDLE = 0
CONNECTING = 1
NEEDS_AUTH = 2
SHOW_FORGET_CONFIRM = 3
FORGETTING = 4
DISCONNECTING = 5
class NavButton(Widget):
def __init__(self, text: str | Callable[[], str]):
super().__init__()
self.text = text
self.set_rect(rl.Rectangle(0, 0, 400, 100))
def _render(self, _):
color = rl.Color(70, 74, 82, 255) if self.is_pressed else rl.Color(52, 55, 62, 255)
rl.draw_rectangle_rounded(self._rect, 0.6, 10, color)
text = self.text() if callable(self.text) else self.text
gui_label(self.rect, text, font_size=60, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
class NetworkUI(Widget):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self._wifi_manager = wifi_manager
self._current_panel: PanelType = PanelType.WIFI
self._wifi_panel = WifiManagerUI(wifi_manager)
self._advanced_panel = AdvancedNetworkSettings(wifi_manager)
# Imported lazily: the setup zipapp uses WifiManagerUI (never builds NetworkUI), and eagerly
# importing esim -> esim_scanner -> selfdrive.ui.ui_state pulls a cereal SubMaster + typed
# params at import time, which don't exist in the setup zipapp (crashes it -> stuck on IQ logo).
from iqpilot.system.ui.widgets.esim import EsimPanel
self._esim_panel = EsimPanel()
self._back_button = NavButton(lambda: tr("Back"))
self._back_button.set_click_callback(lambda: self._set_current_panel(PanelType.WIFI))
self._advanced_button = NavButton(lambda: tr("Advanced"))
self._advanced_button.set_click_callback(lambda: self._set_current_panel(PanelType.ADVANCED))
self._esim_button = NavButton(lambda: tr("eSIM"))
self._esim_button.set_click_callback(lambda: self._set_current_panel(PanelType.ESIM))
self._esim_supported = False
threading.Thread(target=self._detect_esim_support, daemon=True).start()
def _detect_esim_support(self):
try:
self._esim_supported = self._esim_panel.is_supported()
except Exception:
self._esim_supported = False
def show_event(self):
if self._current_panel != PanelType.WIFI:
self._set_current_panel(PanelType.WIFI)
else:
self._wifi_panel.show_event()
def hide_event(self):
if self._current_panel == PanelType.WIFI:
self._wifi_panel.hide_event()
elif self._current_panel == PanelType.ESIM:
self._esim_panel.hide_event()
def _render(self, _):
content_rect = rl.Rectangle(self._rect.x, self._rect.y + self._back_button.rect.height + 40,
self._rect.width, self._rect.height - self._back_button.rect.height - 40)
if self._current_panel == PanelType.WIFI:
self._wifi_panel.render(content_rect)
right_x = self._rect.x + self._rect.width - self._advanced_button.rect.width
self._advanced_button.set_position(right_x, self._rect.y + 20)
self._advanced_button.render()
if self._esim_supported:
esim_x = right_x - self._esim_button.rect.width - 20
self._esim_button.set_position(esim_x, self._rect.y + 20)
self._esim_button.render()
elif self._current_panel == PanelType.ESIM:
self._back_button.set_position(self._rect.x, self._rect.y + 20)
self._back_button.render()
self._esim_panel.render(content_rect)
else:
self._back_button.set_position(self._rect.x, self._rect.y + 20)
self._back_button.render()
self._advanced_panel.render(content_rect)
def _set_current_panel(self, panel: PanelType):
if panel == self._current_panel:
return
if self._current_panel == PanelType.WIFI:
self._wifi_panel.hide_event()
elif self._current_panel == PanelType.ESIM:
self._esim_panel.hide_event()
self._current_panel = panel
if self._current_panel == PanelType.WIFI:
self._wifi_panel.show_event()
elif self._current_panel == PanelType.ESIM:
self._esim_panel.show_event()
class AdvancedNetworkSettings(Widget):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self._wifi_manager = wifi_manager
self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
self._params = Params()
self._keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
# Tethering
self._tethering_action = ToggleAction(initial_state=False)
tethering_btn = ListItem(lambda: tr("Enable Tethering"), action_item=self._tethering_action, callback=self._toggle_tethering)
# Edit tethering password
self._tethering_password_action = ButtonAction(lambda: tr("EDIT"))
tethering_password_btn = ListItem(lambda: tr("Tethering Password"), action_item=self._tethering_password_action, callback=self._edit_tethering_password)
# Roaming toggle
roaming_enabled = self._params.get_bool("GsmRoaming")
self._roaming_action = ToggleAction(initial_state=roaming_enabled)
self._roaming_btn = ListItem(lambda: tr("Enable Roaming"), action_item=self._roaming_action, callback=self._toggle_roaming)
# Cellular metered toggle
cellular_metered = self._params.get_bool("GsmMetered")
self._cellular_metered_action = ToggleAction(initial_state=cellular_metered)
self._cellular_metered_btn = ListItem(lambda: tr("Cellular Metered"),
description=lambda: tr("Prevent large data uploads when on a metered cellular connection"),
action_item=self._cellular_metered_action, callback=self._toggle_cellular_metered)
# APN setting
self._apn_btn = button_item(lambda: tr("APN Setting"), lambda: tr("EDIT"), callback=self._edit_apn)
# Wi-Fi metered toggle
self._wifi_metered_action = MultipleButtonAction([lambda: tr("default"), lambda: tr("metered"), lambda: tr("unmetered")], 255, 0,
callback=self._toggle_wifi_metered)
wifi_metered_btn = ListItem(lambda: tr("Wi-Fi Network Metered"), description=lambda: tr("Prevent large data uploads when on a metered Wi-Fi connection"),
action_item=self._wifi_metered_action)
items: list[Widget] = [
tethering_btn,
tethering_password_btn,
text_item(lambda: tr("IP Address"), lambda: self._wifi_manager.ipv4_address),
self._roaming_btn,
self._apn_btn,
self._cellular_metered_btn,
wifi_metered_btn,
button_item(lambda: tr("Hidden Network"), lambda: tr("CONNECT"), callback=self._connect_to_hidden_network),
]
self._scroller = Scroller(items, line_separator=True, spacing=0)
# Set initial config
metered = self._params.get_bool("GsmMetered")
self._wifi_manager.update_gsm_settings(roaming_enabled, self._params.get("GsmApn") or "", metered)
def _on_network_updated(self, networks: list[Network]):
self._tethering_action.set_enabled(True)
self._tethering_action.set_state(self._wifi_manager.is_tethering_active())
self._tethering_password_action.set_enabled(True)
if self._wifi_manager.is_tethering_active() or self._wifi_manager.ipv4_address == "":
self._wifi_metered_action.set_enabled(False)
self._wifi_metered_action.selected_button = 0
elif self._wifi_manager.ipv4_address != "":
metered = self._wifi_manager.current_network_metered
self._wifi_metered_action.set_enabled(True)
self._wifi_metered_action.selected_button = int(metered) if metered in (MeteredType.UNKNOWN, MeteredType.YES, MeteredType.NO) else 0
def _toggle_tethering(self):
checked = self._tethering_action.get_state()
self._tethering_action.set_enabled(False)
if checked:
self._wifi_metered_action.set_enabled(False)
self._wifi_manager.set_tethering_active(checked)
def _toggle_roaming(self):
roaming_state = self._roaming_action.get_state()
self._params.put_bool("GsmRoaming", roaming_state)
self._wifi_manager.update_gsm_settings(roaming_state, self._params.get("GsmApn") or "", self._params.get_bool("GsmMetered"))
def _edit_apn(self):
def update_apn(result):
if result != 1:
return
apn = self._keyboard.text.strip()
if apn == "":
self._params.remove("GsmApn")
else:
self._params.put("GsmApn", apn)
self._wifi_manager.update_gsm_settings(self._params.get_bool("GsmRoaming"), apn, self._params.get_bool("GsmMetered"))
current_apn = self._params.get("GsmApn") or ""
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration"))
self._keyboard.set_text(current_apn)
gui_app.set_modal_overlay(self._keyboard, update_apn)
def _toggle_cellular_metered(self):
metered = self._cellular_metered_action.get_state()
self._params.put_bool("GsmMetered", metered)
self._wifi_manager.update_gsm_settings(self._params.get_bool("GsmRoaming"), self._params.get("GsmApn") or "", metered)
def _toggle_wifi_metered(self, metered):
metered_type = {0: MeteredType.UNKNOWN, 1: MeteredType.YES, 2: MeteredType.NO}.get(metered, MeteredType.UNKNOWN)
self._wifi_metered_action.set_enabled(False)
self._wifi_manager.set_current_network_metered(metered_type)
def _connect_to_hidden_network(self):
def connect_hidden(result):
if result != 1:
return
ssid = self._keyboard.text
if not ssid:
return
def enter_password(result):
password = self._keyboard.text
if password == "":
# connect without password
self._wifi_manager.connect_to_network(ssid, "", hidden=True)
return
self._wifi_manager.connect_to_network(ssid, password, hidden=True)
self._keyboard.reset(min_text_size=0)
self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid))
gui_app.set_modal_overlay(self._keyboard, enter_password)
self._keyboard.reset(min_text_size=1)
self._keyboard.set_title(tr("Enter SSID"), "")
gui_app.set_modal_overlay(self._keyboard, connect_hidden)
def _edit_tethering_password(self):
def update_password(result):
if result != 1:
return
password = self._keyboard.text
self._wifi_manager.set_tethering_password(password)
self._tethering_password_action.set_enabled(False)
self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
self._keyboard.set_title(tr("Enter new tethering password"), "")
self._keyboard.set_text(self._wifi_manager.tethering_password)
gui_app.set_modal_overlay(self._keyboard, update_password)
def _update_state(self):
self._wifi_manager.process_callbacks()
# konn3kt has no managed cellular SIM, so always expose the GSM/APN settings.
show_cell_settings = True
self._wifi_manager.set_ipv4_forward(show_cell_settings)
self._roaming_btn.set_visible(show_cell_settings)
self._apn_btn.set_visible(show_cell_settings)
self._cellular_metered_btn.set_visible(show_cell_settings)
def _render(self, _):
self._scroller.render(self._rect)
class WifiManagerUI(Widget):
def __init__(self, wifi_manager: WifiManager):
super().__init__()
self._wifi_manager = wifi_manager
self.state: UIState = UIState.IDLE
self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING
self._password_retry: bool = False # for NEEDS_AUTH
self.btn_width: int = 200
self.disconnect_btn_width: int = 300
self.scroll_panel = GuiScrollPanel()
self.keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
self._load_icons()
self._networks: list[Network] = []
self._networks_buttons: dict[str, Button] = {}
self._forget_networks_buttons: dict[str, Button] = {}
self._disconnect_networks_buttons: dict[str, Button] = {}
self._wifi_manager.add_callbacks(need_auth=self._on_need_auth,
activated=self._on_activated,
forgotten=self._on_forgotten,
networks_updated=self._on_network_updated,
disconnected=self._on_disconnected)
def show_event(self):
# start/stop scanning when widget is visible
self._wifi_manager.set_active(True)
def hide_event(self):
self._wifi_manager.set_active(False)
def _load_icons(self):
for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]:
gui_app.texture(icon, ICON_SIZE, ICON_SIZE)
def _update_state(self):
self._wifi_manager.process_callbacks()
def _render(self, rect: rl.Rectangle):
if not self._networks:
cx = rect.x + rect.width / 2
cy = rect.y + rect.height / 2
if self._wifi_manager.is_scanning:
draw_spinner(cx, cy - 50, 46)
gui_label(rl.Rectangle(rect.x, cy + 2, rect.width, 90), tr("Scanning Wi-Fi networks..."), 60,
color=rl.Color(190, 190, 195, 255), alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
else:
gui_label(rl.Rectangle(rect.x, cy - 45, rect.width, 90), tr("No Wi-Fi networks found"), 60,
color=rl.Color(150, 150, 155, 255), alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
return
if self.state == UIState.NEEDS_AUTH and self._state_network:
self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"), tr("for \"{}\"").format(self._state_network.ssid))
self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH)
gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result))
elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network:
confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"))
confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid))
confirm_dialog.reset()
gui_app.set_modal_overlay(confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result))
else:
self._draw_network_list(rect)
def _on_password_entered(self, network: Network, result: int):
if result == 1:
password = self.keyboard.text
self.keyboard.clear()
if len(password) >= MIN_PASSWORD_LENGTH:
self.connect_to_network(network, password)
elif result == 0:
self.state = UIState.IDLE
def on_forgot_confirm_finished(self, network, result: int):
if result == 1:
self.forget_network(network)
elif result == 0:
self.state = UIState.IDLE
def _draw_network_list(self, rect: rl.Rectangle):
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT)
offset = self.scroll_panel.update(rect, content_rect)
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
for i, network in enumerate(self._networks):
y_offset = rect.y + i * ITEM_HEIGHT + offset
item_rect = rl.Rectangle(rect.x, y_offset, rect.width, ITEM_HEIGHT)
if not rl.check_collision_recs(item_rect, rect):
continue
self._draw_network_item(item_rect, network)
if i < len(self._networks) - 1:
line_y = int(item_rect.y + item_rect.height - 1)
rl.draw_line(int(item_rect.x), int(line_y), int(item_rect.x + item_rect.width), line_y, rl.LIGHTGRAY)
rl.end_scissor_mode()
def _draw_network_item(self, rect, network: Network):
spacing = 50
show_disconnect = network.is_connected and self.state not in (UIState.CONNECTING, UIState.DISCONNECTING)
reserved = self.btn_width * 2 + (self.disconnect_btn_width + spacing if show_disconnect else 0)
ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - reserved, ITEM_HEIGHT)
signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
security_icon_rect = rl.Rectangle(signal_icon_rect.x - spacing - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
# Teal accent bar on the connected network
if network.is_connected and self.state != UIState.CONNECTING:
rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y + (ITEM_HEIGHT - 72) / 2, 6, 72), 1.0, 4, TEAL)
status_text = ""
if self.state == UIState.CONNECTING and self._state_network:
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = tr("CONNECTING...")
elif self.state == UIState.FORGETTING and self._state_network:
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = tr("FORGETTING...")
elif self.state == UIState.DISCONNECTING and self._state_network:
if self._state_network.ssid == network.ssid:
self._networks_buttons[network.ssid].set_enabled(False)
status_text = tr("DISCONNECTING...")
elif network.security_type == SecurityType.UNSUPPORTED:
self._networks_buttons[network.ssid].set_enabled(False)
else:
self._networks_buttons[network.ssid].set_enabled(True)
self._networks_buttons[network.ssid].render(ssid_rect)
if status_text:
status_text_rect = rl.Rectangle(security_icon_rect.x - 410, rect.y, 410, ITEM_HEIGHT)
gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
else:
# If the network is saved, show the "Forget" button
if network.is_saved:
forget_btn_rect = rl.Rectangle(
security_icon_rect.x - self.btn_width - spacing,
rect.y + (ITEM_HEIGHT - 80) / 2,
self.btn_width,
80,
)
self._forget_networks_buttons[network.ssid].render(forget_btn_rect)
if show_disconnect:
disconnect_btn_rect = rl.Rectangle(
forget_btn_rect.x - self.disconnect_btn_width - spacing,
forget_btn_rect.y,
self.disconnect_btn_width,
80,
)
self._disconnect_networks_buttons[network.ssid].render(disconnect_btn_rect)
self._draw_status_icon(security_icon_rect, network)
self._draw_signal_strength_icon(signal_icon_rect, network)
def _networks_buttons_callback(self, network):
if not network.is_saved and network.security_type != SecurityType.OPEN:
self.state = UIState.NEEDS_AUTH
self._state_network = network
self._password_retry = False
elif not network.is_connected:
self.connect_to_network(network)
def _forget_networks_buttons_callback(self, network):
self.state = UIState.SHOW_FORGET_CONFIRM
self._state_network = network
def _disconnect_networks_buttons_callback(self, network):
self.disconnect_network(network)
def _draw_status_icon(self, rect, network: Network):
"""Draw the status icon based on network's connection state"""
icon_file = None
if network.is_connected and self.state != UIState.CONNECTING:
icon_file = "icons/checkmark.png"
elif network.security_type == SecurityType.UNSUPPORTED:
icon_file = "icons/circled_slash.png"
elif network.security_type != SecurityType.OPEN:
icon_file = "icons/lock_closed.png"
if not icon_file:
return
texture = gui_app.texture(icon_file, ICON_SIZE, ICON_SIZE)
icon_rect = rl.Vector2(rect.x, rect.y + (ICON_SIZE - texture.height) / 2)
tint = TEAL if (network.is_connected and self.state != UIState.CONNECTING) else rl.WHITE
rl.draw_texture_v(texture, icon_rect, tint)
def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: Network):
"""Draw the Wi-Fi signal strength icon based on network's signal strength"""
strength_level = max(0, min(3, round(network.strength / 33.0)))
rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE)
def connect_to_network(self, network: Network, password=''):
self.state = UIState.CONNECTING
self._state_network = network
if network.is_saved and not password:
self._wifi_manager.activate_connection(network.ssid)
else:
self._wifi_manager.connect_to_network(network.ssid, password, security_type=network.security_type)
def forget_network(self, network: Network):
self.state = UIState.FORGETTING
self._state_network = network
self._wifi_manager.forget_connection(network.ssid)
def disconnect_network(self, network: Network):
self.state = UIState.DISCONNECTING
self._state_network = network
self._wifi_manager.disconnect_connection(network.ssid)
def _on_network_updated(self, networks: list[Network]):
self._networks = networks
for n in self._networks:
self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI,
font_size=45)
self._forget_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
self._disconnect_networks_buttons[n.ssid] = Button(tr("Disconnect"), partial(self._disconnect_networks_buttons_callback, n),
button_style=ButtonStyle.FORGET_WIFI, font_size=45)
self._disconnect_networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid())
def _on_need_auth(self, ssid):
network = next((n for n in self._networks if n.ssid == ssid), None)
if network:
self.state = UIState.NEEDS_AUTH
self._state_network = network
self._password_retry = True
def _on_activated(self):
if self.state == UIState.CONNECTING:
self.state = UIState.IDLE
def _on_forgotten(self):
if self.state == UIState.FORGETTING:
self.state = UIState.IDLE
def _on_disconnected(self):
if self.state in (UIState.CONNECTING, UIState.DISCONNECTING):
self.state = UIState.IDLE
def main():
gui_app.init_window("Wi-Fi Manager")
wifi_ui = WifiManagerUI(WifiManager())
for _ in gui_app.render():
wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100))
gui_app.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,108 @@
import pyray as rl
from iqpilot.system.ui.lib.application import FontWeight
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget, DialogResult
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.widgets.label import gui_label
from iqpilot.system.ui.widgets.scroller_tici import Scroller
# Constants
MARGIN = 50
TITLE_FONT_SIZE = 70
ITEM_HEIGHT = 135
BUTTON_SPACING = 50
BUTTON_HEIGHT = 160
ITEM_SPACING = 50
LIST_ITEM_SPACING = 25
TEAL = rl.Color(16, 185, 169, 255)
OPTION_RADIUS = 28
DIALOG_BTN_RADIUS = 44
DISABLED_BTN_COLOR = rl.Color(45, 47, 52, 255)
def _latin_only(s: str) -> bool:
# Names within Latin + Latin-1 + Latin Extended-A render cleanly in the normal UI font.
return all(ord(c) < 0x250 for c in s)
class _OptionButton(Button):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.selected = False
self._border_radius = OPTION_RADIUS
def _render(self, _):
if self.selected:
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, TEAL)
self._label.render(self._rect)
else:
super()._render(_)
class MultiOptionDialog(Widget):
def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM):
super().__init__()
self.title = title
self.options = options
self.current = current
self.selection = current
self._result: DialogResult = DialogResult.NO_ACTION
# Create scroller with option buttons. Latin names use the normal font; everything
# else (CJK, Cyrillic, Arabic, ...) keeps the broad-coverage font passed by the caller.
self.option_buttons = [_OptionButton(option, click_callback=lambda opt=option: self._on_option_clicked(opt),
font_weight=(FontWeight.MEDIUM if _latin_only(option) else option_font_weight),
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.NORMAL,
text_padding=50, elide_right=True) for option in options]
self.scroller = Scroller(self.option_buttons, spacing=LIST_ITEM_SPACING)
self.cancel_button = Button(lambda: tr("Cancel"), click_callback=lambda: self._set_result(DialogResult.CANCEL))
self.select_button = Button(lambda: tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM),
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT)
self.cancel_button._border_radius = DIALOG_BTN_RADIUS
self.select_button._border_radius = DIALOG_BTN_RADIUS
def _set_result(self, result: DialogResult):
self._result = result
def _on_option_clicked(self, option):
self.selection = option
def _render(self, rect):
dialog_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, rect.height - 2 * MARGIN)
rl.draw_rectangle_rounded(dialog_rect, 0.02, 20, rl.Color(30, 30, 30, 255))
content_rect = rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN,
dialog_rect.width - 2 * MARGIN, dialog_rect.height - 2 * MARGIN)
gui_label(rl.Rectangle(content_rect.x, content_rect.y, content_rect.width, TITLE_FONT_SIZE), self.title, 70, font_weight=FontWeight.BOLD)
# Options area
options_y = content_rect.y + TITLE_FONT_SIZE + ITEM_SPACING
options_h = content_rect.height - TITLE_FONT_SIZE - BUTTON_HEIGHT - 2 * ITEM_SPACING
options_rect = rl.Rectangle(content_rect.x, options_y, content_rect.width, options_h)
# Mark the selected option (teal highlight handled by _OptionButton)
for i, option in enumerate(self.options):
self.option_buttons[i].selected = (option == self.selection)
self.option_buttons[i].set_rect(rl.Rectangle(0, 0, options_rect.width, ITEM_HEIGHT))
self.scroller.render(options_rect)
# Buttons
button_y = content_rect.y + content_rect.height - BUTTON_HEIGHT
button_w = (content_rect.width - BUTTON_SPACING) / 2
cancel_rect = rl.Rectangle(content_rect.x, button_y, button_w, BUTTON_HEIGHT)
self.cancel_button.render(cancel_rect)
select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT)
select_enabled = self.selection != self.current
self.select_button.set_enabled(select_enabled)
sr = DIALOG_BTN_RADIUS / (min(select_rect.width, select_rect.height) / 2)
rl.draw_rectangle_rounded(select_rect, sr, 10, TEAL if select_enabled else DISABLED_BTN_COLOR)
self.select_button.render(select_rect)
return self._result

View File

@@ -0,0 +1,387 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
import pyray as rl
import numpy as np
from collections.abc import Callable
from iqpilot.common.filter_simple import FirstOrderFilter, BounceFilter
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.nav_widget import NavWidget
ITEM_SPACING = 20
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
ANIMATION_SCALE = 0.6
PAGE_SLIDER_MARGIN = 18
PAGE_SLIDER_GLOW_W = 360
PAGE_SLIDER_GLOW_H = 18
PAGE_SLIDER_PEAK = 220
PAGE_SLIDER_END_TAPER = 95
PAGE_EDGE_FADE_W = 55
MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
DO_ZOOM = False
DO_JELLO = False
SCROLL_BAR = False
class LineSeparator(Widget):
def __init__(self, height: int = 1):
super().__init__()
self._rect = rl.Rectangle(0, 0, 0, height)
def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
super().set_parent_rect(parent_rect)
self._rect.width = parent_rect.width
def _render(self, _):
rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
LINE_COLOR)
class Scroller(Widget):
def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING,
line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING):
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._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
# layout state
self._visible_items: list[Widget] = []
self._content_size: float = 0.0
self._scroll_offset: float = 0.0
self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
# 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
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(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
def add_widgets(self, items: list[Widget]) -> None:
for item in items:
self.add_widget(item)
@property
def items(self) -> list[Widget]:
return self._items
def move_item(self, from_idx: int, to_idx: int):
if from_idx == to_idx or not (0 <= from_idx < len(self._items)):
return
self._items.insert(to_idx, self._items.pop(from_idx))
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 round(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 _layout(self):
self._visible_items = [item for item in self._items if item.is_visible]
# Add line separator between items
if self._line_separator is not None:
l = len(self._visible_items)
for i in range(1, len(self._visible_items)):
self._visible_items.insert(l - i, self._line_separator)
self._content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in self._visible_items)
self._content_size += self._spacing * (len(self._visible_items) - 1)
self._content_size += self._pad_start + self._pad_end
self._scroll_offset = self._get_scroll(self._visible_items, self._content_size)
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
self._item_pos_filter.update(self._scroll_offset)
cur_pos = 0
for idx, item in enumerate(self._visible_items):
spacing = self._spacing if (idx > 0) else self._pad_start
# Nicely lay out items horizontally/vertically
if self._horizontal:
x = self._rect.x + cur_pos + spacing
y = self._rect.y + (self._rect.height - item.rect.height) / 2
cur_pos += item.rect.width + spacing
else:
x = self._rect.x + (self._rect.width - item.rect.width) / 2
y = self._rect.y + cur_pos + spacing
cur_pos += item.rect.height + spacing
# Consider scroll
if self._horizontal:
x += self._scroll_offset
else:
y += self._scroll_offset
# Add some jello effect when scrolling
if DO_JELLO:
if self._horizontal:
cx = self._rect.x + self._rect.width / 2
jello_offset = self._scroll_offset - np.interp(x + item.rect.width / 2,
[self._rect.x, cx, self._rect.x + self._rect.width],
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
x -= np.clip(jello_offset, -20, 20)
else:
cy = self._rect.y + self._rect.height / 2
jello_offset = self._scroll_offset - np.interp(y + item.rect.height / 2,
[self._rect.y, cy, self._rect.y + self._rect.height],
[self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x])
y -= np.clip(jello_offset, -20, 20)
# Update item state
item.set_position(round(x), round(y)) # round to prevent jumping when settling
item.set_parent_rect(self._rect)
def _render(self, _):
for item in self._visible_items:
item_visible = rl.check_collision_recs(item.rect, self._rect)
if hasattr(item, "set_scroll_active"):
item.set_scroll_active(item_visible)
# Skip rendering if not in viewport
if not item_visible:
continue
# Scale each element around its own origin when scrolling
scale = self._zoom_filter.x
if scale != 1.0:
rl.rl_push_matrix()
rl.rl_scalef(scale, scale, 1.0)
rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale,
(1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0)
item.render()
rl.rl_pop_matrix()
else:
item.render()
# Draw scroll indicator
if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0:
_real_content_size = self._content_size - self._rect.height + self._txt_scroll_indicator.height
scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height
scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_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()
if self._reset_scroll_at_show:
self.scroll_panel.set_offset(0.0)
for item in self._items:
item.show_event()
def hide_event(self):
super().hide_event()
for item in self._items:
item.hide_event()
def draw_scroller_page_slider(scroller: Scroller, rect: rl.Rectangle) -> None:
if not scroller._horizontal:
return
max_off = scroller._content_size - scroller.rect.width
if max_off <= 1:
return
progress = max(0.0, min(1.0, (-scroller.scroll_panel.get_offset()) / max_off))
from iqpilot.ui.theme import NeonTheme
c = NeonTheme.glow(255)
r, g, b = c.r, c.g, c.b
glow_w = PAGE_SLIDER_GLOW_W
h = PAGE_SLIDER_GLOW_H
end = PAGE_SLIDER_END_TAPER
left = rect.x - end
right = rect.x + rect.width - glow_w + end
x = int(left + progress * (right - left))
bottom = int(rect.y + rect.height)
rl.draw_rectangle_gradient_v(x, bottom - h, glow_w, h, rl.Color(r, g, b, 0), rl.Color(r, g, b, PAGE_SLIDER_PEAK))
black, blank = rl.Color(0, 0, 0, 255), rl.Color(0, 0, 0, 0)
rl.draw_rectangle_gradient_h(x, bottom - h, end, h, black, blank)
rl.draw_rectangle_gradient_h(x + glow_w - end, bottom - h, end, h, blank, black)
def draw_scroller_edge_fades(rect: rl.Rectangle) -> None:
fw = PAGE_EDGE_FADE_W
x, y = int(rect.x), int(rect.y)
w, h = int(rect.width), int(rect.height)
black = rl.Color(0, 0, 0, 255)
blank = rl.Color(0, 0, 0, 0)
rl.draw_rectangle_gradient_h(x, y, fw, h, black, blank)
rl.draw_rectangle_gradient_h(x + w - fw, y, fw, h, blank, black)
class NavScroller(NavWidget):
"""Full screen Scroller that supports the nav stack with swipe-to-dismiss animations.
Subclasses add their items via ``self._scroller.add_widgets([...])``. Built on the existing
``Scroller`` so old callers are unaffected.
"""
def __init__(self, **kwargs):
super().__init__()
kwargs.setdefault('snap_items', False)
self._scroller = Scroller([], **kwargs)
self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing)
PAGE_SLIDER_MARGIN = PAGE_SLIDER_MARGIN
PAGE_SLIDER_GLOW_W = PAGE_SLIDER_GLOW_W
PAGE_SLIDER_GLOW_H = PAGE_SLIDER_GLOW_H
PAGE_SLIDER_PEAK = PAGE_SLIDER_PEAK
PAGE_SLIDER_END_TAPER = PAGE_SLIDER_END_TAPER
PAGE_SLIDER_RGB = (0x3A, 0xDD, 0xC6)
PAGE_EDGE_FADE_W = PAGE_EDGE_FADE_W
def _back_enabled(self) -> bool:
return self._scroller._horizontal or self._scroller.scroll_panel.get_offset() >= -20
def _draw_page_slider(self):
draw_scroller_page_slider(self._scroller, self._rect)
def _draw_edge_fades(self):
draw_scroller_edge_fades(self._rect)
def _render(self, _):
self._scroller.render(self._rect)
self._draw_edge_fades()
self._draw_page_slider()
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
class NavRawScrollPanel(NavWidget):
BACK_TOUCH_AREA_PERCENTAGE = 1.0
def __init__(self):
super().__init__()
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scroll_panel.set_enabled(lambda: self.enabled and not self.is_dismissing)
def show_event(self):
super().show_event()
self._scroll_panel.set_offset(0)
def _back_enabled(self) -> bool:
return self._scroll_panel.get_offset() >= -20

View File

@@ -0,0 +1,88 @@
import pyray as rl
from iqpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from iqpilot.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, _):
pass
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()

View File

@@ -0,0 +1,209 @@
from collections.abc import Callable
import pyray as rl
from iqpilot.system.ui.lib.application import gui_app, FontWeight
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.label import UnifiedLabel
from iqpilot.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_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
self._confirmed_time = 0.0
self._confirm_callback_called = False # we keep dialog open by default, only call once
self._start_x_circle = 0.0
self._scroll_x_circle = 0.0
self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._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, smooth: bool = False):
if smooth:
self._opacity_filter.update(opacity)
else:
self._opacity_filter.x = opacity
@property
def slider_percentage(self):
activated_pos = -self._bg_txt.width + self._circle_bg_txt.width
return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0)
def _on_confirm(self):
if self._confirm_callback:
self._confirm_callback()
def _handle_mouse_event(self, mouse_event):
super()._handle_mouse_event(mouse_event)
if mouse_event.left_pressed:
# touch rect goes to the padding
circle_button_rect = rl.Rectangle(
self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2,
self._rect.y,
self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2,
self._rect.height,
)
if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
self._start_x_circle = mouse_event.pos.x
self._is_dragging_circle = True
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 _bg_rgb(self) -> tuple[int, int, int]:
return (255, 255, 255)
def _circle_rgb(self) -> tuple[int, int, int]:
return (255, 255, 255)
def _render(self, _):
# TODO: iOS text shimmering animation
op = self._opacity_filter.x
white = rl.Color(255, 255, 255, int(255 * op))
bg_tint = rl.Color(*self._bg_rgb(), int(255 * op))
circle_tint = rl.Color(*self._circle_rgb(), int(255 * op))
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, bg_tint)
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_filter.x)))
label_rect = rl.Rectangle(
self._rect.x + 20,
self._rect.y,
self._rect.width - self._circle_bg_txt.width - 20 * 2.5,
self._rect.height,
)
self._label.render(label_rect)
# circle and arrow
rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, circle_tint)
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
def _accent_rgb(self) -> tuple[int, int, int]:
from iqpilot.ui.theme import NeonTheme
c = NeonTheme.glow(255)
return (c.r, c.g, c.b)
def _bg_rgb(self) -> tuple[int, int, int]:
return self._accent_rgb()
def _circle_rgb(self) -> tuple[int, int, int]:
return self._accent_rgb()
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
def _circle_rgb(self) -> tuple[int, int, int]:
return (255, 255, 255) # keep the red circle texture's own color

View File

@@ -0,0 +1,81 @@
import pyray as rl
from collections.abc import Callable
from iqpilot.system.ui.lib.application import MousePos
from iqpilot.system.ui.widgets import Widget
ON_COLOR = rl.Color(51, 171, 76, 255)
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
KNOB_COLOR = rl.WHITE
DISABLED_ON_COLOR = rl.Color(0x22, 0x77, 0x22, 255) # Dark green when disabled + on
DISABLED_OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
DISABLED_KNOB_COLOR = rl.Color(0x88, 0x88, 0x88, 255)
WIDTH, HEIGHT = 230, 80
BG_HEIGHT = 60
ANIMATION_SPEED = 8.0
class Toggle(Widget):
def __init__(self, initial_state: bool = False, callback: Callable[[bool], None] | None = None):
super().__init__()
self._state = initial_state
self._callback = callback
self._enabled = True
self._progress = 1.0 if initial_state else 0.0
self._target = self._progress
self._clicked = False
def set_rect(self, rect: rl.Rectangle):
self._rect = rl.Rectangle(rect.x, rect.y, WIDTH, HEIGHT)
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self._enabled:
return
self._clicked = True
self._state = not self._state
self._target = 1.0 if self._state else 0.0
if self._callback:
self._callback(self._state)
def get_state(self) -> bool:
return self._state
def set_state(self, state: bool):
self._state = state
self._target = 1.0 if state else 0.0
def is_enabled(self):
return self._enabled
def update(self):
if abs(self._progress - self._target) > 0.01:
delta = rl.get_frame_time() * ANIMATION_SPEED
self._progress += delta if self._progress < self._target else -delta
self._progress = max(0.0, min(1.0, self._progress))
def _render(self, rect: rl.Rectangle):
self.update()
if self._enabled:
bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress)
knob_color = KNOB_COLOR
else:
bg_color = self._blend_color(DISABLED_OFF_COLOR, DISABLED_ON_COLOR, self._progress)
knob_color = DISABLED_KNOB_COLOR
# Draw background
bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT)
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
# Draw knob
knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress
knob_y = self._rect.y + HEIGHT / 2
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, knob_color)
# TODO: use click callback
clicked = self._clicked
self._clicked = False
return clicked
def _blend_color(self, c1, c2, t):
return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255)