IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
21
iqpilot/system/ui/README.md
Normal file
21
iqpilot/system/ui/README.md
Normal 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 `_`
|
||||
3
iqpilot/system/ui/iqwidgets/__init__.py
Normal file
3
iqpilot/system/ui/iqwidgets/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
3
iqpilot/system/ui/iqwidgets/lib/__init__.py
Normal file
3
iqpilot/system/ui/iqwidgets/lib/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
36
iqpilot/system/ui/iqwidgets/lib/application.py
Normal file
36
iqpilot/system/ui/iqwidgets/lib/application.py
Normal 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)
|
||||
108
iqpilot/system/ui/iqwidgets/lib/canvas.py
Normal file
108
iqpilot/system/ui/iqwidgets/lib/canvas.py
Normal 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)
|
||||
142
iqpilot/system/ui/iqwidgets/lib/styles.py
Normal file
142
iqpilot/system/ui/iqwidgets/lib/styles.py
Normal 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
|
||||
9
iqpilot/system/ui/iqwidgets/lib/utils.py
Normal file
9
iqpilot/system/ui/iqwidgets/lib/utils.py
Normal 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
|
||||
3
iqpilot/system/ui/iqwidgets/widgets/__init__.py
Normal file
3
iqpilot/system/ui/iqwidgets/widgets/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
30
iqpilot/system/ui/iqwidgets/widgets/helpers/glyphs.py
Normal file
30
iqpilot/system/ui/iqwidgets/widgets/helpers/glyphs.py
Normal 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)
|
||||
1354
iqpilot/system/ui/iqwidgets/widgets/list_view.py
Normal file
1354
iqpilot/system/ui/iqwidgets/widgets/list_view.py
Normal file
File diff suppressed because it is too large
Load Diff
0
iqpilot/system/ui/lib/__init__.py
Normal file
0
iqpilot/system/ui/lib/__init__.py
Normal file
1120
iqpilot/system/ui/lib/application.py
Normal file
1120
iqpilot/system/ui/lib/application.py
Normal file
File diff suppressed because it is too large
Load Diff
204
iqpilot/system/ui/lib/egl.py
Normal file
204
iqpilot/system/ui/lib/egl.py
Normal 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
|
||||
67
iqpilot/system/ui/lib/emoji.py
Normal file
67
iqpilot/system/ui/lib/emoji.py
Normal 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)
|
||||
98
iqpilot/system/ui/lib/multilang.py
Normal file
98
iqpilot/system/ui/lib/multilang.py
Normal 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
|
||||
47
iqpilot/system/ui/lib/networkmanager.py
Normal file
47
iqpilot/system/ui/lib/networkmanager.py
Normal 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)
|
||||
139
iqpilot/system/ui/lib/os_update.py
Normal file
139
iqpilot/system/ui/lib/os_update.py
Normal 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)
|
||||
34
iqpilot/system/ui/lib/raylib_compat.py
Normal file
34
iqpilot/system/ui/lib/raylib_compat.py
Normal 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)
|
||||
293
iqpilot/system/ui/lib/screen_recorder.py
Normal file
293
iqpilot/system/ui/lib/screen_recorder.py
Normal 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)
|
||||
136
iqpilot/system/ui/lib/scroll_panel.py
Normal file
136
iqpilot/system/ui/lib/scroll_panel.py
Normal 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)
|
||||
230
iqpilot/system/ui/lib/scroll_panel2.py
Normal file
230
iqpilot/system/ui/lib/scroll_panel2.py
Normal 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)
|
||||
744
iqpilot/system/ui/lib/setup_ble.py
Normal file
744
iqpilot/system/ui/lib/setup_ble.py
Normal 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
|
||||
372
iqpilot/system/ui/lib/setup_controller.py
Normal file
372
iqpilot/system/ui/lib/setup_controller.py
Normal 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)}
|
||||
238
iqpilot/system/ui/lib/shader_polygon.py
Normal file
238
iqpilot/system/ui/lib/shader_polygon.py
Normal 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()
|
||||
36
iqpilot/system/ui/lib/text_measure.py
Normal file
36
iqpilot/system/ui/lib/text_measure.py
Normal 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
|
||||
23
iqpilot/system/ui/lib/utils.py
Normal file
23
iqpilot/system/ui/lib/utils.py
Normal 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)
|
||||
1091
iqpilot/system/ui/lib/wifi_manager.py
Normal file
1091
iqpilot/system/ui/lib/wifi_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
107
iqpilot/system/ui/lib/wrap_text.py
Normal file
107
iqpilot/system/ui/lib/wrap_text.py
Normal 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
187
iqpilot/system/ui/mici_reset.py
Executable 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
931
iqpilot/system/ui/mici_setup.py
Executable 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
201
iqpilot/system/ui/mici_updater.py
Executable 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
15
iqpilot/system/ui/reset.py
Executable 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
15
iqpilot/system/ui/setup.py
Executable 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
169
iqpilot/system/ui/spinner.py
Executable 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
111
iqpilot/system/ui/text.py
Executable 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
163
iqpilot/system/ui/tici_reset.py
Executable 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
765
iqpilot/system/ui/tici_setup.py
Executable 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
174
iqpilot/system/ui/tici_updater.py
Executable 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
15
iqpilot/system/ui/updater.py
Executable 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()
|
||||
461
iqpilot/system/ui/widgets/__init__.py
Normal file
461
iqpilot/system/ui/widgets/__init__.py
Normal 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()
|
||||
303
iqpilot/system/ui/widgets/button.py
Normal file
303
iqpilot/system/ui/widgets/button.py
Normal 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)
|
||||
114
iqpilot/system/ui/widgets/confirm_dialog.py
Normal file
114
iqpilot/system/ui/widgets/confirm_dialog.py
Normal 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="")
|
||||
247
iqpilot/system/ui/widgets/esim.py
Normal file
247
iqpilot/system/ui/widgets/esim.py
Normal 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)
|
||||
131
iqpilot/system/ui/widgets/esim_scanner.py
Normal file
131
iqpilot/system/ui/widgets/esim_scanner.py
Normal 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
|
||||
302
iqpilot/system/ui/widgets/html_render.py
Normal file
302
iqpilot/system/ui/widgets/html_render.py
Normal 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
|
||||
232
iqpilot/system/ui/widgets/inputbox.py
Normal file
232
iqpilot/system/ui/widgets/inputbox.py
Normal 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))
|
||||
316
iqpilot/system/ui/widgets/keyboard.py
Normal file
316
iqpilot/system/ui/widgets/keyboard.py
Normal 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()
|
||||
863
iqpilot/system/ui/widgets/label.py
Normal file
863
iqpilot/system/ui/widgets/label.py
Normal 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)
|
||||
518
iqpilot/system/ui/widgets/list_view.py
Normal file
518
iqpilot/system/ui/widgets/list_view.py
Normal 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)
|
||||
418
iqpilot/system/ui/widgets/mici_keyboard.py
Normal file
418
iqpilot/system/ui/widgets/mici_keyboard.py
Normal 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()
|
||||
260
iqpilot/system/ui/widgets/nav_widget.py
Normal file
260
iqpilot/system/ui/widgets/nav_widget.py
Normal 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)
|
||||
581
iqpilot/system/ui/widgets/network.py
Normal file
581
iqpilot/system/ui/widgets/network.py
Normal 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()
|
||||
108
iqpilot/system/ui/widgets/option_dialog.py
Normal file
108
iqpilot/system/ui/widgets/option_dialog.py
Normal 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
|
||||
387
iqpilot/system/ui/widgets/scroller.py
Normal file
387
iqpilot/system/ui/widgets/scroller.py
Normal 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
|
||||
88
iqpilot/system/ui/widgets/scroller_tici.py
Normal file
88
iqpilot/system/ui/widgets/scroller_tici.py
Normal 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()
|
||||
209
iqpilot/system/ui/widgets/slider.py
Normal file
209
iqpilot/system/ui/widgets/slider.py
Normal 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
|
||||
81
iqpilot/system/ui/widgets/toggle.py
Normal file
81
iqpilot/system/ui/widgets/toggle.py
Normal 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)
|
||||
Reference in New Issue
Block a user