IQ.Pilot Release Commit @ bec7652

This commit is contained in:
IQ.Lvbs history cleanup
2026-08-22 23:42:41 -05:00
commit 58039e647c
4603 changed files with 1236178 additions and 0 deletions

View File

View File

@@ -0,0 +1,957 @@
import time
import os
import pyray as rl
from collections.abc import Callable
from enum import IntEnum
from iqpilot.cereal import log
from iqpilot.common.params import Params
from iqpilot.common.basedir import BASEDIR
from iqpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert
from iqpilot.selfdrive.ui.widgets.setup import SetupWidget
from iqpilot.selfdrive.ui.widgets.inspire_widget import InspireWidget
from iqpilot.selfdrive.ui.widgets.map_panel_widget import MapPanelWidget
from iqpilot.ui.layouts.settings.drive_history import TripsLayout
from iqpilot.selfdrive.ui.layouts.sidebar import NETWORK_TYPES
from iqpilot.selfdrive.ui.lib.wifi_ssid import current_ssid
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
from iqpilot.system.ui.lib.multilang import tr, trn
from iqpilot.system.ui.lib.wrap_text import wrap_text
from iqpilot.system.ui.widgets.label import gui_label, UnifiedLabel
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
STATUS_BAR_HEIGHT = 120
HEAD_BUTTON_FONT_SIZE = 40
CONTENT_MARGIN = 40
SPACING = 25
TILE_GAP = 24
STATS_PANEL_VERTICAL_INSET = TILE_GAP
REFRESH_INTERVAL = 10.0
CHANGELOG_REFRESH_INTERVAL = 15.0
HOLD_THRESHOLD = 0.6 # seconds to trigger the panel picker
PANEL_KEY = "HomePanelWidget"
PANEL_CHANGELOG = "changelog"
PANEL_STATS = "stats"
PANEL_MAP = "map"
PANEL_INSPIRE = "inspire"
PICKER_BG = rl.Color(20, 21, 26, 230)
PICKER_CARD = rl.Color(34, 36, 44, 255)
PICKER_CARD_HOVER = rl.Color(48, 51, 60, 255)
PICKER_BORDER = rl.Color(255, 255, 255, 30)
PICKER_TEAL = rl.Color(16, 185, 169, 255)
PICKER_SEL_BORDER = rl.Color(16, 185, 169, 200)
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
# Status-light severity colors
STATUS_GOOD = rl.Color(16, 185, 169, 255) # teal
STATUS_WARN = rl.Color(245, 166, 35, 255) # orange
STATUS_DANGER = rl.Color(226, 72, 58, 255) # red
class ChangelogWidget(Widget):
PANEL_BG_COLOR = rl.Color(34, 36, 42, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 26)
BODY_COLOR = rl.Color(235, 235, 235, 255)
HEADING_COLOR = rl.Color(255, 255, 255, 255)
def __init__(self):
super().__init__()
self._show_all = False
self._latest_text = ""
self._all_text = ""
self._render_latest: list[dict] = []
self._render_all: list[dict] = []
self._wrap_width = 0
self._last_load = 0.0
self._scroll_px = 0.0
self._max_scroll = 0.0
self._is_dragging = False
self._drag_last_y = 0.0
self._text_rect = rl.Rectangle(0, 0, 0, 0)
self._btn_rect = rl.Rectangle(0, 0, 0, 0)
self._latest_btn = Button("Latest", self._show_latest, button_style=ButtonStyle.PRIMARY, font_size=28)
self._all_btn = Button("All", self._show_all_logs, button_style=ButtonStyle.NORMAL, font_size=28)
self._load_changelog(force=True)
def show_event(self):
self._load_changelog(force=True)
def _show_latest(self) -> None:
self._show_all = False
self._scroll_px = 0.0
self._is_dragging = False
def _show_all_logs(self) -> None:
self._show_all = True
self._scroll_px = 0.0
self._is_dragging = False
def _load_changelog(self, force: bool = False) -> None:
now = time.monotonic()
if not force and (now - self._last_load) < CHANGELOG_REFRESH_INTERVAL:
return
self._last_load = now
paths = [os.path.join(BASEDIR, "iqpilot", "docs", "CHANGELOG.md")]
content = ""
for p in paths:
try:
with open(p, encoding="utf-8") as f:
content = f.read().strip()
if content:
break
except OSError:
pass
if not content:
content = "No changelog found.\n\nAdd iqpilot/docs/CHANGELOG.md."
ordered = self._reorder_sections_newest_first(content)
self._latest_text = self._build_latest_text(ordered)
self._all_text = ordered
self._render_latest = []
self._render_all = []
self._wrap_width = 0
def _reorder_sections_newest_first(self, content: str) -> str:
lines = content.splitlines()
intro: list[str] = []
sections: list[list[str]] = []
current: list[str] | None = None
for line in lines:
if line.startswith("## "):
if current is not None:
sections.append(current)
current = [line]
else:
if current is None:
intro.append(line)
else:
current.append(line)
if current is not None:
sections.append(current)
out: list[str] = []
if intro:
out.extend(intro)
out.append("")
for i, section in enumerate(reversed(sections)):
out.extend(section)
if i != len(sections) - 1:
out.append("")
return "\n".join(out).strip()
def _build_latest_text(self, content: str) -> str:
lines = content.splitlines()
if not lines:
return "No updates available."
out: list[str] = []
section_count = 0
for line in lines:
if line.startswith("## "):
section_count += 1
if section_count > 2:
break
out.append(line)
return "\n".join(out).strip() or content
@staticmethod
def _clean_inline_markdown(text: str) -> str:
return text.replace("**", "").replace("`", "").strip()
def _build_render_lines(self, text: str, width: int) -> list[dict]:
lines: list[dict] = []
for raw in text.splitlines():
stripped = raw.strip()
if not stripped:
lines.append({"text": "", "font_size": 16, "font_weight": FontWeight.NORMAL, "indent": 0, "color": self.BODY_COLOR, "height": 18})
continue
font_size = 34
font_weight = FontWeight.NORMAL
indent = 0
color = self.BODY_COLOR
text_line = stripped
extra_spacing = 0
if stripped.startswith("### "):
text_line = self._clean_inline_markdown(stripped[4:])
font_size = 34
font_weight = FontWeight.BOLD
color = self.HEADING_COLOR
extra_spacing = 8
elif stripped.startswith("## "):
text_line = self._clean_inline_markdown(stripped[3:])
font_size = 38
font_weight = FontWeight.BOLD
color = self.HEADING_COLOR
extra_spacing = 10
elif stripped.startswith("# "):
text_line = self._clean_inline_markdown(stripped[2:])
font_size = 42
font_weight = FontWeight.BOLD
color = self.HEADING_COLOR
extra_spacing = 12
elif stripped.startswith(("- ", "* ")):
text_line = "" + self._clean_inline_markdown(stripped[2:])
font_size = 34
indent = 8
else:
text_line = self._clean_inline_markdown(stripped)
font = gui_app.font(font_weight)
wrapped = wrap_text(font, text_line, font_size, max(50, width - indent))
if not wrapped:
wrapped = [text_line]
for i, w in enumerate(wrapped):
line_indent = indent if i == 0 else indent + 18
line_h = int(font_size * 1.15)
lines.append({
"text": w,
"font_size": font_size,
"font_weight": font_weight,
"indent": line_indent,
"color": color,
"height": line_h,
})
if extra_spacing > 0:
lines.append({"text": "", "font_size": extra_spacing, "font_weight": FontWeight.NORMAL, "indent": 0, "color": self.BODY_COLOR, "height": extra_spacing})
return lines
def _ensure_wrapped(self, text_w: int):
if text_w <= 0:
return
if self._wrap_width == text_w and self._render_latest and self._render_all:
return
self._wrap_width = text_w
self._render_latest = self._build_render_lines(self._latest_text, text_w)
self._render_all = self._build_render_lines(self._all_text, text_w)
@staticmethod
def _total_height(lines: list[dict]) -> int:
return sum(line["height"] for line in lines)
def _clamp_scroll(self):
self._scroll_px = max(0.0, min(self._max_scroll, self._scroll_px))
def _handle_mouse_press(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, self._text_rect):
self._is_dragging = True
self._drag_last_y = mouse_pos.y
def _handle_mouse_event(self, mouse_event):
if not self._is_dragging or not mouse_event.left_down:
return
dy = mouse_event.pos.y - self._drag_last_y
self._drag_last_y = mouse_event.pos.y
self._scroll_px -= dy
self._clamp_scroll()
def _handle_mouse_release(self, mouse_pos: MousePos):
self._is_dragging = False
def _render(self, rect: rl.Rectangle):
self._load_changelog()
rl.draw_rectangle_rounded(rect, 0.06, 24, self.PANEL_BG_COLOR)
rl.draw_rectangle_rounded_lines_ex(rect, 0.06, 24, 2, self.PANEL_BORDER)
title_rect = rl.Rectangle(rect.x + 36, rect.y + 24, rect.width - 72, 50)
gui_label(title_rect, "Latest Updates", 44, rl.WHITE, font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
btn_w = 150
btn_h = 54
btn_gap = 12
self._btn_rect = rl.Rectangle(rect.x + rect.width - (btn_w * 2) - btn_gap - 36, rect.y + 84, (btn_w * 2) + btn_gap, btn_h)
latest_rect = rl.Rectangle(self._btn_rect.x, self._btn_rect.y, btn_w, btn_h)
all_rect = rl.Rectangle(self._btn_rect.x + btn_w + btn_gap, self._btn_rect.y, btn_w, btn_h)
self._latest_btn.set_button_style(ButtonStyle.PRIMARY if not self._show_all else ButtonStyle.NORMAL)
self._all_btn.set_button_style(ButtonStyle.PRIMARY if self._show_all else ButtonStyle.NORMAL)
self._latest_btn.render(latest_rect)
self._all_btn.render(all_rect)
self._text_rect = rl.Rectangle(rect.x + 36, rect.y + 154, rect.width - 72, rect.height - 182)
self._ensure_wrapped(int(self._text_rect.width))
lines = self._render_all if self._show_all else self._render_latest
total_h = self._total_height(lines)
self._max_scroll = max(0.0, total_h - self._text_rect.height)
self._clamp_scroll()
# Clip content region and draw formatted lines
rl.begin_scissor_mode(int(self._text_rect.x), int(self._text_rect.y), int(self._text_rect.width), int(self._text_rect.height))
y = self._text_rect.y - self._scroll_px
for line in lines:
line_h = line["height"]
if line["text"] and (y + line_h) >= self._text_rect.y and y <= (self._text_rect.y + self._text_rect.height):
font = gui_app.font(line["font_weight"])
x = self._text_rect.x + line["indent"]
rl.draw_text_ex(font, line["text"], rl.Vector2(x, y), line["font_size"], 0, line["color"])
y += line_h
rl.end_scissor_mode()
if self._max_scroll > 0.0:
hint = "Drag to scroll"
hint_size = measure_text_cached(gui_app.font(FontWeight.NORMAL), hint, 24)
hint_x = self._text_rect.x + self._text_rect.width - hint_size.x
hint_y = rect.y + rect.height - 12 - hint_size.y
rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), hint, rl.Vector2(hint_x, hint_y), 24, 0, rl.Color(180, 180, 180, 220))
def _format_updater_description(description: str | None) -> str:
brand = "IQ.Pilot"
if not description:
return brand
cleaned = description.strip()
lower = cleaned.lower()
if lower.startswith("iqpilot"):
cleaned = cleaned[len("iqpilot"):].lstrip(" -:/")
if cleaned.lower().startswith(brand.lower()):
return cleaned
return f"{brand} {cleaned}" if cleaned else brand
class LauncherTile(Widget):
"""A large offroad launcher tile: teal-gradient icon over a dark rounded card, label beneath."""
BG = rl.Color(34, 36, 42, 255)
BG_PRESSED = rl.Color(50, 53, 61, 255)
BORDER = rl.Color(255, 255, 255, 26)
def __init__(self, icon_path: str, label: str | Callable[[], str], on_click: Callable[[], None] | None = None):
super().__init__()
self._label = label
self._icon_path = icon_path
self._icon = gui_app.texture(icon_path, 256, 256, keep_aspect_ratio=True)
if on_click is not None:
self.set_click_callback(on_click)
def set_label(self, label: str | Callable[[], str]) -> None:
self._label = label
def set_icon_path(self, icon_path: str) -> None:
if icon_path != self._icon_path:
self._icon_path = icon_path
self._icon = gui_app.texture(icon_path, 256, 256, keep_aspect_ratio=True)
def _render(self, rect: rl.Rectangle):
pressed = self.is_pressed
rl.draw_rectangle_rounded(rect, 0.16, 24, self.BG_PRESSED if pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.16, 24, 2, self.BORDER)
# Icon centered in the upper portion of the tile
icon_size = min(rect.width, rect.height) * 0.44
cx = rect.x + rect.width / 2
cy = rect.y + rect.height * 0.40
icon_dst = rl.Rectangle(cx - icon_size / 2, cy - icon_size / 2, icon_size, icon_size)
src = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
rl.draw_texture_pro(self._icon, src, icon_dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Label, centered beneath the icon
font = gui_app.font(FontWeight.MEDIUM)
label_size = 48
label = self._label() if callable(self._label) else self._label
ts = measure_text_cached(font, label, label_size)
label_x = rect.x + (rect.width - ts.x) / 2
label_y = rect.y + rect.height * 0.75
rl.draw_text_ex(font, label, rl.Vector2(int(label_x), int(label_y)), label_size, 0, rl.WHITE)
class HomeLayoutState(IntEnum):
HOME = 0
UPDATE = 1
ALERTS = 2
class HomeLayout(Widget):
def __init__(self):
super().__init__()
self.params = Params()
self.update_alert = UpdateAlert()
self.offroad_alert = OffroadAlert()
self._layout_widgets = {HomeLayoutState.UPDATE: self.update_alert, HomeLayoutState.ALERTS: self.offroad_alert}
self.current_state = HomeLayoutState.HOME
self.last_refresh = 0
self.settings_callback: Callable[[], None] | None = None
self.stats_callback: Callable[[], None] | None = None
self.nav_callback: Callable[[], None] | None = None
self.routes_callback: Callable[[], None] | None = None
self.update_available = False
self.alert_count = 0
self._version_text = ""
self._version_commit_text = ""
self._status_version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(185, 185, 190, 255),
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
wrap_text=False, scroll=True)
self._prev_update_available = False
self._prev_alerts_present = False
# Status-bar state
self._status_color = STATUS_GOOD
self._status_word = tr("READY")
self._expanded = False
self._expanded_metrics: list[tuple[str, str, rl.Color]] = []
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._on_wifi = False
self._battery_pct: int | None = None
self._battery_charging = False
self.status_bar_rect = rl.Rectangle(0, 0, 0, 0)
self.content_rect = rl.Rectangle(0, 0, 0, 0)
self.left_column_rect = rl.Rectangle(0, 0, 0, 0)
self.right_column_rect = rl.Rectangle(0, 0, 0, 0)
self._tile_rects: list[rl.Rectangle] = []
self.update_notif_rect = rl.Rectangle(0, 0, 200, 60)
self.alert_notif_rect = rl.Rectangle(0, 0, 220, 60)
self._setup_widget = SetupWidget()
self._changelog_widget = ChangelogWidget()
self._inspire_widget = InspireWidget()
self._map_panel_widget = MapPanelWidget()
self._stats_panel_widget = TripsLayout()
# Right-panel widget selection
saved = self.params.get(PANEL_KEY) or ""
self._panel_widget = saved if saved in (PANEL_CHANGELOG, PANEL_STATS, PANEL_MAP, PANEL_INSPIRE) else PANEL_CHANGELOG
# Hold-to-pick
self._press_start: float | None = None
self._press_origin: tuple[float, float] = (0.0, 0.0)
self._press_scrolled = False # True once the current press moved — don't restart timer
self._show_picker = False
self._picker_hover: str | None = None
self._picker_ignore_release = False # swallow the release that opened the picker
# Status-bar icons (white, tinted at draw time)
self._icon_wifi = gui_app.texture("icons/iq/wifi.png", 64, 64, keep_aspect_ratio=True)
self._icon_battery = gui_app.texture("icons/iq/battery.png", 72, 72, keep_aspect_ratio=True)
_net_base = "icons_mici/settings/network/"
self._cell_strength_icons = [
gui_app.texture(f"{_net_base}cell_strength_none.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_medium.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_high.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_full.png", 64, 64, keep_aspect_ratio=True),
]
# wifi has no "high" variant: none/low/medium/full only
self._wifi_strength_icons = [
gui_app.texture(f"{_net_base}wifi_strength_none.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_medium.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
]
self._net_strength = 0
# Launcher tiles
self._tiles = [
LauncherTile("icons/iq/tile_settings.png", lambda: tr("Settings"), self._on_settings_tile),
LauncherTile("icons/iq/tile_stats.png", lambda: tr("Stats"), self._on_stats_tile),
LauncherTile("icons/iq/tile_nav.png", lambda: tr("Navigation"), self._on_nav_tile),
LauncherTile("icons/iq/tile_routes.png", lambda: tr("Routes"), self._on_routes_tile),
]
self._setup_callbacks()
def show_event(self):
self._changelog_widget.show_event()
self.last_refresh = time.monotonic()
self._refresh()
def _setup_callbacks(self):
self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME))
def set_settings_callback(self, callback: Callable):
self.settings_callback = callback
def set_stats_callback(self, callback: Callable):
self.stats_callback = callback
def set_nav_callback(self, callback: Callable):
self.nav_callback = callback
def set_routes_callback(self, callback: Callable):
self.routes_callback = callback
# Tile actions
def _on_settings_tile(self):
if self.settings_callback:
self.settings_callback()
def _on_stats_tile(self):
if self.stats_callback:
self.stats_callback()
def _on_nav_tile(self):
if self.nav_callback:
self.nav_callback()
def _on_routes_tile(self):
if self.routes_callback:
self.routes_callback()
def _set_state(self, state: HomeLayoutState):
# propagate show/hide events
if state != self.current_state:
if state in self._layout_widgets:
self._layout_widgets[state].show_event()
if self.current_state in self._layout_widgets:
self._layout_widgets[self.current_state].hide_event()
self.current_state = state
def _render(self, rect: rl.Rectangle):
current_time = time.monotonic()
if current_time - self.last_refresh >= REFRESH_INTERVAL:
self._refresh()
self.last_refresh = current_time
self._render_status_bar()
if self.current_state == HomeLayoutState.HOME:
self._render_home_content()
elif self.current_state == HomeLayoutState.UPDATE:
self.update_alert.render(self.content_rect)
elif self.current_state == HomeLayoutState.ALERTS:
self.offroad_alert.render(self.content_rect)
def _update_state(self):
self.status_bar_rect = rl.Rectangle(
self._rect.x + CONTENT_MARGIN, self._rect.y + CONTENT_MARGIN,
self._rect.width - 2 * CONTENT_MARGIN, STATUS_BAR_HEIGHT
)
content_y = self._rect.y + CONTENT_MARGIN + STATUS_BAR_HEIGHT + SPACING
content_height = self._rect.height - CONTENT_MARGIN - STATUS_BAR_HEIGHT - SPACING - CONTENT_MARGIN
self.content_rect = rl.Rectangle(
self._rect.x + CONTENT_MARGIN, content_y, self._rect.width - 2 * CONTENT_MARGIN, content_height
)
right_width = min(820, self.content_rect.width * 0.46)
left_width = self.content_rect.width - right_width - SPACING
self.left_column_rect = rl.Rectangle(self.content_rect.x, self.content_rect.y, left_width, self.content_rect.height)
self.right_column_rect = rl.Rectangle(
self.content_rect.x + left_width + SPACING, self.content_rect.y, right_width, self.content_rect.height
)
# 2x2 tile grid
tile_w = (self.left_column_rect.width - TILE_GAP) / 2
tile_h = (self.left_column_rect.height - TILE_GAP) / 2
self._tile_rects = []
for i in range(4):
col = i % 2
row = i // 2
tx = self.left_column_rect.x + col * (tile_w + TILE_GAP)
ty = self.left_column_rect.y + row * (tile_h + TILE_GAP)
self._tile_rects.append(rl.Rectangle(tx, ty, tile_w, tile_h))
self._update_status_info()
self._update_hold()
def _clear_panel_hold(self):
self._press_start = None
self._press_scrolled = False
def _update_hold(self):
if not ui_state.prime_state.is_paired() or self._show_picker:
self._clear_panel_hold()
return
if self._press_start is None or self._press_scrolled:
return
if time.monotonic() - self._press_start >= HOLD_THRESHOLD:
self._show_picker = True
self._press_start = None
self._picker_ignore_release = True
def _update_status_info(self):
# Read the expanded-status toggle every frame (not gated by deviceState updates)
self._expanded = self.params.get_bool("IQExpandedStatus")
sm = ui_state.sm
# Network + battery — updated from cached state every frame so values are never stale
_ds_cached = sm['deviceState']
self._net_type = NETWORK_TYPES.get(_ds_cached.networkType.raw, self._net_type)
self._on_wifi = _ds_cached.networkType.raw in (NetworkType.wifi, NetworkType.ethernet)
_strength = _ds_cached.networkStrength
self._net_strength = max(0, min(5, _strength.raw + 1)) if _strength.raw > 0 else 0
if sm.updated['deviceState']:
ds = sm['deviceState']
# Battery (not present on all hardware/messages)
try:
self._battery_pct = int(ds.batteryPercent)
self._battery_charging = bool(ds.batteryStatus == "Charging")
except (AttributeError, ValueError):
self._battery_pct = None
# Status pill — computed every frame from cached state so it's always current.
# severity: 1 = warning (orange), 2 = error (red). No issues -> READY (teal).
_ds = sm['deviceState']
issues: list[tuple[int, str]] = []
_ts = _ds.thermalStatus
if _ts == ThermalStatus.red:
issues.append((2, tr("TEMP HIGH")))
elif _ts == ThermalStatus.yellow:
issues.append((1, tr("TEMP OK")))
if ui_state.panda_type == log.PandaState.PandaType.unknown:
issues.append((2, tr("UNAVAILABLE")))
_last_ping = _ds.lastAthenaPingTime
if _last_ping == 0:
issues.append((1, tr("KONN3KT OFFLINE")))
elif time.monotonic_ns() - _last_ping >= 80_000_000_000:
issues.append((2, tr("KONN3KT ERROR")))
if issues:
severity, word = max(issues, key=lambda i: i[0])
else:
severity, word = 0, tr("READY")
self._status_color = (STATUS_GOOD, STATUS_WARN, STATUS_DANGER)[severity]
self._status_word = word
# Expanded status (classic-UI style) — rebuilt every frame from cached state
if self._expanded:
ds = sm['deviceState']
ts = ds.thermalStatus
if ts == ThermalStatus.green:
temp = (tr("TEMP"), tr("GOOD"), STATUS_GOOD)
elif ts == ThermalStatus.yellow:
temp = (tr("TEMP"), tr("OK"), STATUS_WARN)
else:
temp = (tr("TEMP"), tr("HIGH"), STATUS_DANGER)
if ui_state.panda_type == log.PandaState.PandaType.unknown:
veh = (tr("VEHICLE"), tr("NO PANDA"), STATUS_DANGER)
else:
veh = (tr("VEHICLE"), tr("ONLINE"), STATUS_GOOD)
last_ping = ds.lastAthenaPingTime
if last_ping == 0:
kon = (tr("KONN3KT"), tr("OFFLINE"), STATUS_WARN)
elif time.monotonic_ns() - last_ping < 80_000_000_000:
kon = (tr("KONN3KT"), tr("ONLINE"), STATUS_GOOD)
else:
kon = (tr("KONN3KT"), tr("ERROR"), STATUS_DANGER)
self._expanded_metrics = [temp, veh, kon]
else:
self._expanded_metrics = []
def _handle_mouse_press(self, mouse_pos: MousePos):
if (self.current_state == HomeLayoutState.HOME and ui_state.prime_state.is_paired()
and not self._show_picker and rl.check_collision_point_rec(mouse_pos, self.right_column_rect)):
self._press_start = time.monotonic()
self._press_origin = (mouse_pos.x, mouse_pos.y)
self._press_scrolled = False
def _handle_mouse_event(self, mouse_event: MouseEvent):
if self._press_start is None or self._press_scrolled or not mouse_event.left_down:
return
dx = mouse_event.pos.x - self._press_origin[0]
dy = mouse_event.pos.y - self._press_origin[1]
if dx * dx + dy * dy > 18 * 18:
self._press_start = None
self._press_scrolled = True
def _handle_mouse_release(self, mouse_pos: MousePos):
self._clear_panel_hold()
if self._show_picker:
if self._picker_ignore_release:
self._picker_ignore_release = False
return
if self._picker_hover is not None:
self._panel_widget = self._picker_hover
self.params.put(PANEL_KEY, self._panel_widget)
if self._panel_widget == PANEL_INSPIRE:
self._inspire_widget.show_event()
elif self._panel_widget == PANEL_CHANGELOG:
self._changelog_widget.show_event()
self._show_picker = False
self._picker_hover = None
return
super()._handle_mouse_release(mouse_pos)
if self.update_available and rl.check_collision_point_rec(mouse_pos, self.update_notif_rect):
self._set_state(HomeLayoutState.UPDATE)
elif self.alert_count > 0 and rl.check_collision_point_rec(mouse_pos, self.alert_notif_rect):
self._set_state(HomeLayoutState.ALERTS)
def _render_status_bar(self):
rect = self.status_bar_rect
cy = rect.y + rect.height / 2
font_bold = gui_app.font(FontWeight.BOLD)
font = gui_app.font(FontWeight.MEDIUM)
word_fs = 50
text_fs = 44
pad = 36
gap = 26
light_d = 54 # status light region width
# --- measure the left cluster so we can wrap it in a rounded pill ---
# The summary word (READY / KONN3KT ERROR / ...) duplicates the expanded TEMP/VEHICLE/KONN3KT
# chips, so drop it when expanded — the color-coded status dot still conveys severity.
show_word = not (self._expanded and self._expanded_metrics)
word_w = measure_text_cached(font_bold, self._status_word, word_fs).x if show_word else 0
net_text = current_ssid(self._on_wifi) or tr(self._net_type)
net_w = measure_text_cached(font, net_text, text_fs).x
_sig_icons = self._wifi_strength_icons if self._on_wifi else self._cell_strength_icons
_icon_signal = _sig_icons[min(self._net_strength, len(_sig_icons) - 1)]
cluster_w = pad + light_d + gap + (word_w + gap if show_word else 0) + _icon_signal.width + 14 + net_w
batt_text = None
if self._battery_pct is not None:
batt_text = f"{self._battery_pct}%"
cluster_w += gap + self._icon_battery.width + 10 + measure_text_cached(font, batt_text, text_fs).x
cluster_w += pad
pill = rl.Rectangle(rect.x, rect.y, cluster_w, rect.height)
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(28, 30, 36, 255))
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, rl.Color(255, 255, 255, 28))
# status light dot with a faint halo
x = rect.x + pad
halo = rl.Color(self._status_color.r, self._status_color.g, self._status_color.b, 70)
rl.draw_circle(int(x + light_d / 2), int(cy), 32, halo)
rl.draw_circle(int(x + light_d / 2), int(cy), 22, self._status_color)
x += light_d + gap
# status word (summary) — hidden when the expanded chips already show the breakdown
if show_word:
word_size = measure_text_cached(font_bold, self._status_word, word_fs)
rl.draw_text_ex(font_bold, self._status_word, rl.Vector2(int(x), int(cy - word_size.y / 2)), word_fs, 0, rl.WHITE)
x += word_w + gap
# network: signal icon (strength-aware) + type
rl.draw_texture(_icon_signal, int(x), int(cy - _icon_signal.height / 2), rl.WHITE)
x += _icon_signal.width + 14
net_size = measure_text_cached(font, net_text, text_fs)
rl.draw_text_ex(font, net_text, rl.Vector2(int(x), int(cy - net_size.y / 2)), text_fs, 0, rl.Color(215, 215, 215, 255))
x += net_w + gap
# battery, if available
if batt_text is not None:
rl.draw_texture(self._icon_battery, int(x), int(cy - self._icon_battery.height / 2), rl.WHITE)
x += self._icon_battery.width + 10
batt_size = measure_text_cached(font, batt_text, text_fs)
rl.draw_text_ex(font, batt_text, rl.Vector2(int(x), int(cy - batt_size.y / 2)), text_fs, 0, rl.Color(215, 215, 215, 255))
right_x = rect.x + rect.width
version_fs = 44
version_text = self._version_commit_text if self._expanded else self._version_text
version_size = measure_text_cached(font, version_text, version_fs)
version_available = max(1.0, right_x - x - gap)
version_w = min(version_size.x, version_available)
version_rect = rl.Rectangle(right_x - version_w, rect.y, version_w, rect.height)
# --- expanded status chips (classic-UI style), gated by the Visuals toggle ---
if self._expanded and self._expanded_metrics:
chip_x = rect.x + cluster_w + gap
chip_h = rect.height - 16
chip_pad = 22
dot_r = 11
label_fs = 30
max_x = version_rect.x - 30
for label, value, color in self._expanded_metrics:
ctext = f"{label} {value}"
tw = measure_text_cached(font, ctext, label_fs).x
chip_w = chip_pad + dot_r * 2 + 14 + tw + chip_pad
if chip_x + chip_w > max_x:
break
chip = rl.Rectangle(chip_x, rect.y + 8, chip_w, chip_h)
rl.draw_rectangle_rounded(chip, 0.5, 16, rl.Color(28, 30, 36, 255))
rl.draw_rectangle_rounded_lines_ex(chip, 0.5, 16, 2, rl.Color(255, 255, 255, 22))
ccx = chip.x + chip_pad + dot_r
rl.draw_circle(int(ccx), int(cy), dot_r, color)
rl.draw_text_ex(font, ctext, rl.Vector2(int(ccx + dot_r + 14), int(cy - label_fs / 2 - 3)), label_fs, 0, rl.WHITE)
chip_x += chip_w + 16
# --- right cluster: version (small), then notification chips to its left ---
if version_size.x <= version_rect.width:
rl.draw_text_ex(font, version_text, rl.Vector2(int(right_x - version_size.x), int(cy - version_size.y / 2)),
version_fs, 0, rl.Color(185, 185, 190, 255))
else:
self._status_version_label.set_text(version_text)
self._status_version_label.render(version_rect)
chip_x = version_rect.x - 30
if self.alert_count > 0:
self.alert_notif_rect = rl.Rectangle(chip_x - self.alert_notif_rect.width, cy - 30, self.alert_notif_rect.width, 60)
self._draw_chip(self.alert_notif_rect, trn("{} ALERT", "{} ALERTS", self.alert_count).format(self.alert_count),
rl.Color(226, 72, 58, 255) if self.current_state != HomeLayoutState.ALERTS else rl.Color(245, 92, 78, 255))
chip_x = self.alert_notif_rect.x - 16
if self.update_available:
self.update_notif_rect = rl.Rectangle(chip_x - self.update_notif_rect.width, cy - 30, self.update_notif_rect.width, 60)
self._draw_chip(self.update_notif_rect, tr("UPDATE"),
rl.Color(54, 77, 239, 255) if self.current_state != HomeLayoutState.UPDATE else rl.Color(75, 95, 255, 255))
def _draw_chip(self, chip_rect: rl.Rectangle, text: str, color: rl.Color):
rl.draw_rectangle_rounded(chip_rect, 0.4, 10, color)
font = gui_app.font(FontWeight.MEDIUM)
text_size = measure_text_cached(font, text, HEAD_BUTTON_FONT_SIZE)
text_x = chip_rect.x + (chip_rect.width - text_size.x) / 2
text_y = chip_rect.y + (chip_rect.height - text_size.y) / 2
rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), HEAD_BUTTON_FONT_SIZE, 0, rl.WHITE)
def _render_home_content(self):
# left: 2x2 launcher tiles
for tile, tile_rect in zip(self._tiles, self._tile_rects, strict=False):
tile.render(tile_rect)
# right: setup until paired, then the user-chosen widget
if not ui_state.prime_state.is_paired():
self._setup_widget.render(self.right_column_rect)
return
self._render_right_panel()
if self._show_picker:
self._render_picker()
def _render_right_panel(self):
rect = self.right_column_rect
if self._panel_widget == PANEL_STATS:
stats_rect = rl.Rectangle(
rect.x,
rect.y + STATS_PANEL_VERTICAL_INSET,
rect.width,
max(1, rect.height - STATS_PANEL_VERTICAL_INSET * 2),
)
self._stats_panel_widget.render(stats_rect)
elif self._panel_widget == PANEL_MAP:
self._map_panel_widget.render(rect)
elif self._panel_widget == PANEL_INSPIRE:
self._inspire_widget.render(rect)
else:
self._changelog_widget.render(rect)
# Hold-progress ring drawn over the panel while user is holding
if self._press_start is not None and not self._show_picker:
held = time.monotonic() - self._press_start
progress = min(1.0, held / HOLD_THRESHOLD)
cx = int(rect.x + rect.width / 2)
cy = int(rect.y + rect.height / 2)
rl.draw_ring(rl.Vector2(cx, cy), 34, 42, -90, -90 + 360 * progress, 40, rl.Color(16, 185, 169, 180))
def _render_picker(self):
rect = self.right_column_rect
rl.draw_rectangle_rounded(rect, 0.06, 24, PICKER_BG)
options = [
(PANEL_CHANGELOG, "Changelog", "Latest updates"),
(PANEL_STATS, "Stats", "Your drive history"),
(PANEL_MAP, "Map", "Last known location"),
(PANEL_INSPIRE, "Inspiration", "Daily message"),
]
TITLE_FS = 58
SUB_FS = 38
CARD_PAD_V = 36
CARD_PAD_H = 32
DOT_MARGIN = 24
card_h = TITLE_FS + 12 + SUB_FS + CARD_PAD_V * 2
outer_pad = 20
gap = 12
n = len(options)
total_cards_h = n * card_h + (n - 1) * gap
start_y = rect.y + (rect.height - total_cards_h) / 2
mp = rl.get_mouse_position()
self._picker_hover = None
font_title = gui_app.font(FontWeight.BOLD)
font_sub = gui_app.font(FontWeight.MEDIUM)
for i, (key, title, sub) in enumerate(options):
cy_card = start_y + i * (card_h + gap)
card = rl.Rectangle(rect.x + outer_pad, cy_card, rect.width - outer_pad * 2, card_h)
hovered = rl.check_collision_point_rec(mp, card)
if hovered:
self._picker_hover = key
is_sel = key == self._panel_widget
bg = PICKER_CARD_HOVER if hovered else PICKER_CARD
rl.draw_rectangle_rounded(card, 0.14, 16, bg)
border = PICKER_SEL_BORDER if is_sel else PICKER_BORDER
rl.draw_rectangle_rounded_lines_ex(card, 0.14, 16, 2 if is_sel else 1, border)
dot_cx = int(card.x + CARD_PAD_H)
dot_cy = int(card.y + card_h / 2)
if is_sel:
rl.draw_circle(dot_cx, dot_cy, 8, PICKER_TEAL)
else:
rl.draw_circle(dot_cx, dot_cy, 8, rl.Color(255, 255, 255, 35))
rl.draw_ring(rl.Vector2(dot_cx, dot_cy), 5.5, 8, 0, 360, 24, rl.Color(255, 255, 255, 55))
tx = int(card.x + CARD_PAD_H + DOT_MARGIN + 6)
title_y = int(card.y + CARD_PAD_V)
sub_y = int(title_y + TITLE_FS + 8)
title_col = rl.WHITE if is_sel else rl.Color(210, 210, 215, 255)
sub_col = PICKER_TEAL if is_sel else rl.Color(130, 130, 138, 255)
rl.draw_text_ex(font_title, title, rl.Vector2(tx, title_y), TITLE_FS, 0, title_col)
rl.draw_text_ex(font_sub, sub, rl.Vector2(tx, sub_y), SUB_FS, 0, sub_col)
# Hint centered below the cards
hint = "Tap a widget to switch • tap outside to dismiss"
font_hint = gui_app.font(FontWeight.MEDIUM)
hw = measure_text_cached(font_hint, hint, 24).x
hint_y = int(start_y + total_cards_h + 18)
rl.draw_text_ex(font_hint, hint,
rl.Vector2(int(rect.x + (rect.width - hw) / 2), hint_y),
24, 0, rl.Color(110, 110, 118, 220))
def _refresh(self):
self._version_text, self._version_commit_text = self._get_version_texts()
update_available = self.update_alert.refresh()
alert_count = self.offroad_alert.refresh()
alerts_present = alert_count > 0
# Show panels on transition from no alert/update to any alerts/update
if not update_available and not alerts_present:
self._set_state(HomeLayoutState.HOME)
elif update_available and ((not self._prev_update_available) or (not alerts_present and self.current_state == HomeLayoutState.ALERTS)):
self._set_state(HomeLayoutState.UPDATE)
elif alerts_present and ((not self._prev_alerts_present) or (not update_available and self.current_state == HomeLayoutState.UPDATE)):
self._set_state(HomeLayoutState.ALERTS)
self.update_available = update_available
self.alert_count = alert_count
self._prev_update_available = update_available
self._prev_alerts_present = alerts_present
def _get_version_texts(self) -> tuple[str, str]:
description = self.params.get("UpdaterCurrentDescription")
version_text = _format_updater_description(description)
if description:
parts = [part.strip() for part in description.split(" / ")]
if len(parts) >= 3 and parts[2]:
return version_text, parts[2]
return version_text, version_text

View File

@@ -0,0 +1,470 @@
import pyray as rl
from dataclasses import dataclass
from enum import Enum, IntEnum, auto
import iqpilot.cereal.messaging as messaging
from iqpilot.system.ui.lib.application import gui_app, MouseEvent
from iqpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
from iqpilot.selfdrive.ui.layouts.home import HomeLayout
from iqpilot.selfdrive.ui.layouts.stats import StatsLayout
from iqpilot.selfdrive.ui.layouts.nav import NavLayout
from iqpilot.selfdrive.ui.layouts.routes import RoutesLayout
from iqpilot.selfdrive.ui.layouts.video_player import VideoPlayerLayout
from iqpilot.selfdrive.ui.layouts.settings_hub import SettingsHubLayout
from iqpilot.selfdrive.ui.layouts.settings.settings import PanelType
from iqpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
from iqpilot.selfdrive.ui.ui_state import device, ui_state
from iqpilot.system.ui.widgets import Widget
from iqpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow
class MainState(IntEnum):
HOME = 0
SETTINGS = 1
ONROAD = 2
STATS = 3
NAV = 4
ROUTES = 5
VIDEO = 6
OFFROAD_TRANSITION_SECONDS = 0.30
TRANSITION_SURFACE_OVERSCAN = 4
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
# iOS-style swipe-from-the-left-edge-to-go-back, generalized across the whole offroad UI (settings
# hub still owns its own panel->grid swipe; this covers the top-level pages).
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back must start within
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to a swipe
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (under arm distance) that cancels it
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of width dragged to complete the pop on release
SWIPE_SETTLE_SECONDS = 0.16 # animate from the release point to done/cancelled (no snap)
class MenuTransitionState(Enum):
IDLE = auto()
PUSHING = auto()
POPPING = auto()
@dataclass
class MenuTransition:
state: MenuTransitionState = MenuTransitionState.IDLE
t: float = 0.0
duration: float = OFFROAD_TRANSITION_SECONDS
from_screen: object | None = None
to_screen: object | None = None
@property
def active(self) -> bool:
return self.state != MenuTransitionState.IDLE
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def _ease_out_cubic(x: float) -> float:
x = _clamp(x)
return 1.0 - pow(1.0 - x, 3.0)
def _ease_emphasized(x: float) -> float:
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
x = _clamp(x)
if x < 0.5:
return 4.0 * x * x * x
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
# Depth cues for the page push/pop (iOS-style): the underneath page parallaxes a fraction of the
# way, dims into the background, and the top card casts a soft shadow off its leading edge.
TRANSITION_PARALLAX = 0.28
TRANSITION_MAX_DIM = 0.5
TRANSITION_SHADOW_W = 32
class MainLayout(Widget):
def __init__(self):
super().__init__()
self._pm = messaging.PubMaster(['bookmarkButton'])
self._sidebar = Sidebar()
# The offroad launcher owns the full screen; the sidebar is reserved for onroad.
self._sidebar.set_visible(False)
self._current_mode = MainState.HOME
self._prev_onroad = False
# Initialize layouts
self._layouts = {
MainState.HOME: HomeLayout(),
MainState.SETTINGS: SettingsHubLayout(),
MainState.ONROAD: AugmentedRoadView(),
MainState.STATS: StatsLayout(),
MainState.NAV: NavLayout(),
MainState.ROUTES: RoutesLayout(),
MainState.VIDEO: VideoPlayerLayout(),
}
self._sidebar_rect = rl.Rectangle(0, 0, 0, 0)
self._content_rect = rl.Rectangle(0, 0, 0, 0)
self._transition = MenuTransition()
# Edge-swipe-back drag state
self._swipe_start_pos = None
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
# Release-settle animation (glide to done/cancelled instead of snapping)
self._swipe_settling = False
self._swipe_settle_from = 0.0
self._swipe_settle_to = 0.0
self._swipe_settle_t = 0.0
self._swipe_render_target: MainState | None = None
self._swipe_completing = False
# Set callbacks
self._setup_callbacks()
self._onboarding_window = OnboardingWindow()
if not self._onboarding_window.completed:
gui_app.set_modal_overlay(self._onboarding_window)
def _render(self, _):
self._handle_onroad_transition()
self._render_main_content()
def _setup_callbacks(self):
self._sidebar.set_callbacks(on_settings=self._on_settings_clicked,
on_flag=self._on_bookmark_clicked,
open_settings=lambda: self.open_settings(PanelType.TOGGLES))
self._layouts[MainState.HOME].set_settings_callback(self.open_settings)
self._layouts[MainState.HOME].set_stats_callback(self.open_stats)
self._layouts[MainState.HOME].set_nav_callback(self.open_nav)
self._layouts[MainState.HOME].set_routes_callback(self.open_routes)
self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state)
self._layouts[MainState.STATS].set_on_back(self._set_mode_for_state)
self._layouts[MainState.NAV].set_on_back(self._set_mode_for_state)
self._layouts[MainState.ROUTES].set_on_back(self._set_mode_for_state)
self._layouts[MainState.ROUTES].set_on_play(self.open_video)
self._layouts[MainState.VIDEO].set_on_back(self.open_routes)
self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked)
device.add_interactive_timeout_callback(self._set_mode_for_state)
def _update_layout_rects(self):
self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height)
x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0
self._content_rect = rl.Rectangle(self._rect.x + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height)
def _handle_onroad_transition(self):
if ui_state.started != self._prev_onroad:
self._prev_onroad = ui_state.started
self._set_mode_for_state()
def _set_mode_for_state(self):
if ui_state.started:
# Don't hide sidebar from interactive timeout
if self._current_mode != MainState.ONROAD:
self._set_sidebar_visible(False)
self._set_current_layout(MainState.ONROAD)
else:
# Offroad launcher owns the full screen; the sidebar is reserved for onroad.
self._set_current_layout(MainState.HOME)
self._set_sidebar_visible(False)
def _set_current_layout(self, layout: MainState):
if self._transition.active:
if layout == MainState.ONROAD or ui_state.started:
self._cancel_transition()
elif layout == self._current_mode:
self._cancel_transition()
return
else:
return
if layout == self._current_mode:
return
old_mode = self._current_mode
if self._should_animate_transition(old_mode, layout):
self._start_transition(old_mode, layout)
return
self._layouts[old_mode].hide_event()
self._current_mode = layout
self._layouts[self._current_mode].show_event()
def _should_animate_transition(self, old_mode: MainState, new_mode: MainState) -> bool:
return (
not ui_state.started
and old_mode != MainState.ONROAD
and new_mode != MainState.ONROAD
and (old_mode == MainState.HOME or new_mode == MainState.HOME)
)
def _start_transition(self, old_mode: MainState, new_mode: MainState):
state = MenuTransitionState.PUSHING if old_mode == MainState.HOME else MenuTransitionState.POPPING
self._transition = MenuTransition(
state=state,
t=0.0,
duration=OFFROAD_TRANSITION_SECONDS,
from_screen=old_mode,
to_screen=new_mode,
)
self._layouts[new_mode].show_event()
def _cancel_transition(self):
pending_mode = self._transition.to_screen
if pending_mode is not None and pending_mode != self._current_mode:
self._layouts[pending_mode].hide_event()
self._transition = MenuTransition()
def _finish_transition(self):
old_mode = self._transition.from_screen
new_mode = self._transition.to_screen
if old_mode is not None:
self._layouts[old_mode].hide_event()
if new_mode is not None:
self._current_mode = new_mode
self._transition = MenuTransition()
def _render_layout(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
if interactive:
layout.render(rect)
return
enabled = layout._enabled
layout.set_enabled(False)
try:
layout.render(rect)
finally:
layout.set_enabled(enabled)
def _render_layout_surface(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
bg_rect = rl.Rectangle(
rect.x - TRANSITION_SURFACE_OVERSCAN,
rect.y - TRANSITION_SURFACE_OVERSCAN,
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
)
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
self._render_layout(layout, rect, interactive)
def _render_layout_surface_translated(self, layout: Widget, rect: rl.Rectangle, x_offset: float):
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
self._render_layout_surface(layout, translated_rect, interactive=False)
def _render_transition(self, content_rect: rl.Rectangle) -> bool:
if not self._transition.active:
return False
from_mode = self._transition.from_screen
to_mode = self._transition.to_screen
if from_mode is None or to_mode is None:
self._finish_transition()
return False
self._transition.t += rl.get_frame_time()
if self._transition.t >= self._transition.duration:
self._finish_transition()
return False
p = _clamp(self._transition.t / self._transition.duration)
e = _ease_emphasized(p)
w = content_rect.width
pushing = self._transition.state == MenuTransitionState.PUSHING
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
if pushing:
top_mode, back_mode = to_mode, from_mode
top_x = _lerp(w, 0.0, e)
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
else:
top_mode, back_mode = from_mode, to_mode
top_x = _lerp(0.0, w, e)
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
back_rect = rl.Rectangle(round(content_rect.x + back_x), content_rect.y, content_rect.width, content_rect.height)
rl.draw_rectangle_rec(content_rect, TRANSITION_SURFACE_BG)
self._render_layout_surface_translated(self._layouts[back_mode], content_rect, back_x)
if back_dim > 0:
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
# soft shadow cast by the top card's leading edge onto the page behind
edge_x = round(content_rect.x + top_x)
if edge_x > content_rect.x:
sh = int(min(TRANSITION_SHADOW_W, edge_x - content_rect.x))
rl.draw_rectangle_gradient_h(edge_x - sh, int(content_rect.y), sh, int(content_rect.height),
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
self._render_layout_surface_translated(self._layouts[top_mode], content_rect, top_x)
return True
def open_settings(self, panel_type: PanelType | None = None):
self._set_current_layout(MainState.SETTINGS)
if panel_type is None:
self._layouts[MainState.SETTINGS].show_grid()
else:
self._layouts[MainState.SETTINGS].set_current_panel(panel_type)
self._set_sidebar_visible(False)
def open_stats(self):
self._set_current_layout(MainState.STATS)
self._set_sidebar_visible(False)
def open_nav(self):
self._set_current_layout(MainState.NAV)
self._set_sidebar_visible(False)
def open_routes(self):
self._set_current_layout(MainState.ROUTES)
self._set_sidebar_visible(False)
def open_video(self, route: str):
self._layouts[MainState.VIDEO].set_route(route)
self._set_current_layout(MainState.VIDEO)
self._set_sidebar_visible(False)
def _on_settings_clicked(self):
self.open_settings()
def _on_bookmark_clicked(self):
user_bookmark = messaging.new_message('bookmarkButton')
user_bookmark.valid = True
self._pm.send('bookmarkButton', user_bookmark)
def _set_sidebar_visible(self, visible: bool):
self._sidebar.set_visible(visible)
self._update_layout_rects()
def _on_onroad_clicked(self):
self._set_sidebar_visible(not self._sidebar.is_visible)
def _back_target(self) -> MainState | None:
"""The page an edge-swipe-back should return to, or None if there's nowhere to go back."""
if ui_state.started:
return None
mode = self._current_mode
if mode == MainState.VIDEO:
return MainState.ROUTES
if mode in (MainState.SETTINGS, MainState.STATS, MainState.NAV, MainState.ROUTES):
# The settings hub owns its own panel->grid swipe; only take over once it's back at the grid.
if mode == MainState.SETTINGS and getattr(self._layouts[MainState.SETTINGS], "_mode", "grid") == "panel":
return None
return MainState.HOME
return None
def _reset_swipe(self):
self._swipe_start_pos = None
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if self._swipe_settling:
return # let the release animation finish before accepting a new gesture
if mouse_event.slot != 0 or self._transition.active or self._back_target() is None:
self._reset_swipe()
return
if mouse_event.left_pressed:
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
self._swipe_start_pos = mouse_event.pos
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
else:
self._reset_swipe()
elif self._swipe_start_pos is not None:
if mouse_event.left_down:
dx = mouse_event.pos.x - self._swipe_start_pos.x
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
if not self._swipe_active and not self._swipe_blocked:
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
self._swipe_blocked = True # user is scrolling, not swiping back
elif dx > EDGE_SWIPE_ARM_DISTANCE:
self._swipe_active = True
if self._swipe_active:
self._swipe_dx = max(0.0, dx)
elif mouse_event.left_released:
if self._swipe_active:
target = self._back_target()
complete = target is not None and self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
self._begin_swipe_settle(target, complete)
self._reset_swipe()
def _complete_back(self, target: MainState):
# The finger already dragged the page most of the way across, so switch instantly (matching the
# settings hub's swipe) rather than replaying the eased transition.
old_mode = self._current_mode
if old_mode == target:
return
self._layouts[old_mode].hide_event()
self._current_mode = target
self._layouts[target].show_event()
def _begin_swipe_settle(self, target: MainState | None, complete: bool):
# On release, glide from where the finger let go to fully-open (complete) or closed (cancel)
# instead of snapping. Needs a back page to slide behind; if there's none, just drop the drag.
if target is None:
return
self._swipe_settle_from = self._swipe_dx
self._swipe_settle_to = self._rect.width if complete else 0.0
self._swipe_render_target = target
self._swipe_completing = complete
self._swipe_settle_t = 0.0
self._swipe_settling = True
def _update_swipe_settle(self) -> bool:
"""Advance the release animation. Returns True while still animating."""
self._swipe_settle_t += rl.get_frame_time()
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
if p < 1.0:
return True
self._swipe_settling = False
target, completing = self._swipe_render_target, self._swipe_completing
self._swipe_render_target = None
self._swipe_completing = False
self._swipe_dx = 0.0
if completing and target is not None:
self._complete_back(target)
return False
def _render_swipe_drag(self, rect: rl.Rectangle, target: MainState):
# Live finger-following pop: the destination sits behind, the current page slides out right.
dx = min(self._swipe_dx, rect.width)
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
self._render_layout_surface_translated(self._layouts[target], rect, dx - rect.width)
self._render_layout_surface_translated(self._layouts[self._current_mode], rect, dx)
def _render_main_content(self):
# Render sidebar (onroad only)
if self._sidebar.is_visible:
self._sidebar.render(self._sidebar_rect)
content_rect = self._content_rect if self._sidebar.is_visible else self._rect
if self._render_transition(content_rect):
return
if self._swipe_settling:
if self._update_swipe_settle():
self._render_swipe_drag(content_rect, self._swipe_render_target)
return
# settled this frame; fall through to render the (possibly switched) current page
elif self._swipe_active:
target = self._back_target()
if target is not None:
self._render_swipe_drag(content_rect, target)
return
self._layouts[self._current_mode].render(content_rect)

View File

@@ -0,0 +1,562 @@
import threading
import time
import pyray as rl
from collections.abc import Callable
import requests
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.lib.nav_helpers import (has_mapbox_token, resolve_mapbox_token,
current_or_last_gps_position)
from iqpilot.ui.onroad.nav_map_panel import NavMapPanel
from iqpilot.ui.onroad.nav_map_utils import build_mapbox_static_url
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, GL_VERSION
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.keyboard import Keyboard
from iqpilot.selfdrive.ui.lib import nav_search
from iqpilot.selfdrive.ui.lib.nav_search import NavSearch, SearchResult
from iqpilot.selfdrive.ui.widgets.interactive_map import InteractiveNavMap
MARGIN = 40
SPACING = 25
SEARCH_HEIGHT = 120
PILL_HEIGHT = 120
MAP_ZOOM = 15.0
MAP_RETRY_INTERVAL = 5.0
PANEL_BG = rl.Color(38, 40, 46, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 38)
MUTED = rl.Color(165, 165, 170, 255)
ROUNDED_TEXTURE_VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
out vec2 fragTexCoord;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
ROUNDED_TEXTURE_FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform vec4 clipRect;
uniform float cornerRadius;
uniform float viewportHeight;
out vec4 fragColor;
float roundedRectDistance(vec2 p, vec2 center, vec2 halfSize, float radius) {
vec2 d = abs(p - center) - (halfSize - vec2(radius));
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius;
}
void main() {
vec2 p = vec2(gl_FragCoord.x, viewportHeight - gl_FragCoord.y);
vec2 center = clipRect.xy + clipRect.zw * 0.5;
vec2 halfSize = clipRect.zw * 0.5;
float radius = min(cornerRadius, min(halfSize.x, halfSize.y));
float dist = roundedRectDistance(p, center, halfSize, radius);
float alpha = 1.0 - smoothstep(0.0, 1.25, dist);
vec4 sampled = texture(texture0, fragTexCoord);
fragColor = vec4(sampled.rgb, sampled.a * alpha);
}
"""
class _MapPreview:
"""Fetches a Mapbox static map (background thread) and caches it as a texture."""
def __init__(self):
self._params = Params()
self._session = requests.Session()
self._texture: rl.Texture | None = None
self._pending: tuple[tuple, bytes] | None = None
self._fetching = False
self._key: tuple | None = None
self._status = "idle"
self._last_attempt = 0.0
self._rounded_shader = None
self._rounded_shader_locs: dict[str, int] = {}
self._clip_rect = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self._corner_radius = rl.ffi.new("float[]", [0.0])
self._viewport_height = rl.ffi.new("float[]", [0.0])
def has_token(self) -> bool:
return has_mapbox_token(self._params)
def _fetch(self, url: str, token: str, key: tuple):
try:
r = self._session.get(url, params={"access_token": token}, timeout=4.0)
if r.status_code == 200 and r.content:
self._pending = (key, r.content)
self._status = "ready"
else:
self._key = None
self._status = "error"
except requests.RequestException:
self._key = None
self._status = "error"
finally:
self._fetching = False
def request(self, lat: float, lon: float, bearing: float, w: float, h: float):
token = resolve_mapbox_token(self._params)
if not token or w < 20 or h < 20:
self._status = "token_missing" if not token else "idle"
return
key = (round(lat, 4), round(lon, 4))
if self._fetching or key == self._key:
return
now = time.monotonic()
if self._status == "error" and now - self._last_attempt < MAP_RETRY_INTERVAL:
return
self._last_attempt = now
self._key = key
self._fetching = True
self._status = "loading"
scale = min(1.0, 1000.0 / max(w, h))
url = build_mapbox_static_url(lat, lon, MAP_ZOOM, bearing, max(1, int(w * scale)), max(1, int(h * scale)))
threading.Thread(target=self._fetch, args=(url, token, key), daemon=True).start()
def _consume(self):
if self._pending is None:
return
_key, data = self._pending
self._pending = None
try:
ext = ".png" if data[:4] == b"\x89PNG" else ".jpg"
img = rl.load_image_from_memory(ext, data, len(data))
tex = rl.load_texture_from_image(img)
rl.unload_image(img)
rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
if self._texture is not None:
rl.unload_texture(self._texture)
self._texture = tex
except Exception:
pass
def _ensure_rounded_shader(self):
if self._rounded_shader is not None:
return
self._rounded_shader = rl.load_shader_from_memory(ROUNDED_TEXTURE_VERTEX_SHADER, ROUNDED_TEXTURE_FRAGMENT_SHADER)
self._rounded_shader_locs = {
"clipRect": rl.get_shader_location(self._rounded_shader, "clipRect"),
"cornerRadius": rl.get_shader_location(self._rounded_shader, "cornerRadius"),
"viewportHeight": rl.get_shader_location(self._rounded_shader, "viewportHeight"),
}
def _draw_texture(self, src: rl.Rectangle, rect: rl.Rectangle, roundness: float):
if roundness <= 0:
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
return
self._ensure_rounded_shader()
self._clip_rect[0:4] = [rect.x, rect.y, rect.width, rect.height]
self._corner_radius[0] = max(0.0, min(rect.width, rect.height) * roundness * 0.5)
self._viewport_height[0] = gui_app.height
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["clipRect"],
self._clip_rect,
rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4,
)
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["cornerRadius"],
self._corner_radius,
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
)
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["viewportHeight"],
self._viewport_height,
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
)
rl.begin_shader_mode(self._rounded_shader)
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
rl.end_shader_mode()
def draw(self, rect: rl.Rectangle, roundness: float = 0.0) -> bool:
self._consume()
if self._texture is None or self._texture.id == 0:
return False
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
src = rl.Rectangle(0, 0, self._texture.width, self._texture.height)
self._draw_texture(src, rect, roundness)
rl.end_scissor_mode()
return True
def status(self) -> str:
if self._fetching:
return "loading"
return self._status
class _Pill(Widget):
"""A rounded destination shortcut: icon + label (Home / Work / Recent)."""
BG = rl.Color(38, 40, 46, 255)
BG_PRESSED = rl.Color(54, 57, 65, 255)
def __init__(self, icon_path: str, label: str, on_click: Callable[[], None] | None = None):
super().__init__()
self._label = label
self._icon = gui_app.texture(icon_path, 56, 56, keep_aspect_ratio=True)
if on_click is not None:
self.set_click_callback(on_click)
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.5, 20, self.BG_PRESSED if self.is_pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.5, 20, 2, PANEL_BORDER)
cy = rect.y + rect.height / 2
x = rect.x + 32
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
x += self._icon.width + 20
font = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(font, self._label, 40)
rl.draw_text_ex(font, self._label, rl.Vector2(int(x), int(cy - ts.y / 2)), 40, 0, rl.WHITE)
ROW_HEIGHT = 116
ROW_GAP = 16
RESULT_NAME = rl.Color(240, 240, 244, 255)
SEARCH_DEBOUNCE = 0.25
class NavLayout(Widget):
"""Offroad Navigate screen: destination search with live Mapbox autocomplete, Home/Work/Recent
shortcuts, and a location map preview. Picking a place writes NavigationDestination (navd routes)."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Navigation")))
self._search_icon = gui_app.texture("icons/iq/search.png", 52, 52, keep_aspect_ratio=True)
self._pin_icon = gui_app.texture("icons/iq/pin.png", 90, 90, keep_aspect_ratio=True)
self._home_icon = gui_app.texture("icons/iq/home.png", 52, 52, keep_aspect_ratio=True)
self._work_icon = gui_app.texture("icons/iq/work.png", 52, 52, keep_aspect_ratio=True)
self._recent_icon = gui_app.texture("icons/iq/recent.png", 52, 52, keep_aspect_ratio=True)
self._keyboard = Keyboard(max_text_size=128, min_text_size=0)
self._map = _MapPreview()
self._imap = self._child(InteractiveNavMap())
self._search = NavSearch()
self._on_back_cb: Callable[[], None] | None = None
self._mode = "browse" # "browse" | "results"
self._purpose = "navigate" # "navigate" | "set_home" | "set_work"
self._query = ""
self._selecting = False
self._status_ts = 0.0
self._pending_exit = False
self._status_msg = ""
self._home: SearchResult | None = None
self._work: SearchResult | None = None
self._recents: list[SearchResult] = []
self._tap_targets: list[tuple[rl.Rectangle, Callable[[], None]]] = []
self._reload_favorites()
def _reload_favorites(self):
self._home = nav_search.get_home()
self._work = nav_search.get_work()
self._recents = nav_search.get_recents()
def set_on_back(self, cb: Callable[[], None]) -> None:
self._on_back_cb = cb
self._header.set_on_back(self._handle_back)
def show_event(self):
super().show_event()
self._mode = "browse"
self._status_msg = ""
self._reload_favorites()
def hide_event(self):
super().hide_event()
# Free the tile cache's GPU memory while the screen is away.
self._imap.release()
def _handle_back(self):
if self._mode == "results":
self._exit_search()
elif self._on_back_cb is not None:
self._on_back_cb()
# --- search lifecycle -------------------------------------------------------
def _open_search(self, purpose: str = "navigate"):
# Full-screen modal keyboard (same as before) — search runs when you hit Done.
self._purpose = purpose
self._search.new_session()
self._keyboard.reset(min_text_size=1)
title = {"set_home": tr("Set Home"), "set_work": tr("Set Work")}.get(purpose, tr("Search"))
self._keyboard.set_title(title, tr("Enter an address or place"))
self._keyboard.set_text(self._query if purpose == "navigate" else "")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_search_done)
def _on_search_done(self, result: int):
if result != 1:
return
self._query = self._keyboard.text.strip()
if not self._query:
return
self._search.search(self._query)
self._mode = "results"
self._status_msg = ""
self._selecting = False
def _exit_search(self):
self._mode = "browse"
self._selecting = False
self._status_msg = ""
self._reload_favorites()
# --- selection (threaded: retrieve coords, then persist) --------------------
def _select_result(self, r: SearchResult):
if self._selecting:
return
self._selecting = True
self._status_msg = tr("Locating...")
threading.Thread(target=self._finish_select, args=(r, self._purpose), daemon=True).start()
def _finish_select(self, r: SearchResult, purpose: str):
full = self._search.retrieve(r)
if full is None or not full.has_coords:
self._status_msg = tr("Couldn't locate that place")
self._selecting = False
return
if purpose == "set_home":
nav_search.save_home(full)
elif purpose == "set_work":
nav_search.save_work(full)
else:
nav_search.set_destination(full.lat, full.lon, full.name)
nav_search.add_recent(full)
self._selecting = False
self._pending_exit = True
def _navigate_place(self, place: SearchResult):
if place is not None and place.has_coords:
nav_search.set_destination(place.lat, place.lon, place.name)
nav_search.add_recent(place)
self._reload_favorites()
self._dest_check_time = 0.0
self._set_status(tr("Destination set"))
def _set_status(self, msg: str):
self._status_msg = msg
self._status_ts = time.monotonic()
def _cancel_nav(self):
nav_search.cancel_navigation()
self._dest_check_time = 0.0
self._set_status(tr("Navigation canceled"))
def _remove_home(self):
nav_search.remove_home()
self._reload_favorites()
def _remove_work(self):
nav_search.remove_work()
self._reload_favorites()
def _remove_recent(self, r: SearchResult):
nav_search.remove_recent(r)
self._reload_favorites()
def _on_home(self):
self._navigate_place(self._home) if self._home is not None else self._open_search("set_home")
def _on_work(self):
self._navigate_place(self._work) if self._work is not None else self._open_search("set_work")
# --- render -----------------------------------------------------------------
def _render(self, rect: rl.Rectangle):
self._tap_targets = []
if self._pending_exit:
self._pending_exit = False
self._exit_search()
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
body = rl.Rectangle(rect.x + MARGIN, header_rect.y + HEADER_HEIGHT + SPACING,
rect.width - 2 * MARGIN, rect.y + rect.height - (header_rect.y + HEADER_HEIGHT + SPACING) - MARGIN)
if self._mode == "results":
self._render_results(body)
else:
self._render_browse(body)
def _row(self, rect: rl.Rectangle, icon, title: str, subtitle: str, on_tap, on_delete=None, pressed_hint=True):
hit = rl.check_collision_point_rec(rl.get_mouse_position(), rect)
bg = rl.Color(54, 57, 65, 255) if (hit and pressed_hint and rl.is_mouse_button_down(0)) else PANEL_BG
rl.draw_rectangle_rounded(rect, 0.35, 20, bg)
rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 20, 2, PANEL_BORDER)
x = rect.x + 32
if icon is not None:
rl.draw_texture(icon, int(x), int(rect.y + rect.height / 2 - icon.height / 2), rl.WHITE)
x += icon.width + 24
text_w = rect.width - (x - rect.x) - (110 if on_delete is not None else 32)
font = gui_app.font(FontWeight.MEDIUM)
if subtitle:
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + 22)), 40, 0, RESULT_NAME)
sub = self._ellipsize(font, subtitle, 30, text_w)
rl.draw_text_ex(font, sub, rl.Vector2(int(x), int(rect.y + 66)), 30, 0, MUTED)
else:
ts = measure_text_cached(font, title, 42)
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + rect.height / 2 - ts.y / 2)), 42, 0, RESULT_NAME)
# Delete (×) button — appended first so a tap on it wins over the row's navigate tap.
if on_delete is not None:
cx, cy = rect.x + rect.width - 60, rect.y + rect.height / 2
del_r = rl.Rectangle(cx - 34, cy - 34, 68, 68)
dhit = rl.check_collision_point_rec(rl.get_mouse_position(), del_r)
rl.draw_circle(int(cx), int(cy), 30, rl.Color(90, 62, 66, 255) if dhit else rl.Color(60, 62, 70, 255))
rl.draw_line_ex(rl.Vector2(cx - 13, cy - 13), rl.Vector2(cx + 13, cy + 13), 4, rl.Color(230, 120, 120, 255))
rl.draw_line_ex(rl.Vector2(cx - 13, cy + 13), rl.Vector2(cx + 13, cy - 13), 4, rl.Color(230, 120, 120, 255))
self._tap_targets.append((del_r, on_delete))
if on_tap is not None:
self._tap_targets.append((rect, on_tap))
@staticmethod
def _ellipsize(font, text: str, size: int, max_w: float) -> str:
if measure_text_cached(font, text, size).x <= max_w:
return text
while text and measure_text_cached(font, text + "", size).x > max_w:
text = text[:-1]
return text + ""
def _render_browse(self, rect: rl.Rectangle):
font = gui_app.font(FontWeight.MEDIUM)
x, w = rect.x, rect.width
y = rect.y
# Search bar
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
cy = bar.y + bar.height / 2
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
ph = tr("Search address or place")
rl.draw_text_ex(font, ph, rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24),
int(cy - 22)), 44, 0, MUTED)
self._tap_targets.append((bar, lambda: self._open_search("navigate")))
y += SEARCH_HEIGHT + SPACING
# Cancel active route (param read throttled)
now = time.monotonic()
if now - getattr(self, "_dest_check_time", 0.0) > 1.0:
self._has_dest = nav_search.has_active_destination()
self._dest_check_time = now
if getattr(self, "_has_dest", False):
cr = rl.Rectangle(x, y, w, ROW_HEIGHT)
chit = rl.check_collision_point_rec(rl.get_mouse_position(), cr)
rl.draw_rectangle_rounded(cr, 0.35, 20, rl.Color(96, 46, 48, 255) if chit else rl.Color(74, 40, 42, 255))
rl.draw_rectangle_rounded_lines_ex(cr, 0.35, 20, 2, rl.Color(210, 90, 90, 120))
label = tr("Cancel navigation")
ls = measure_text_cached(font, label, 42)
rl.draw_text_ex(font, label, rl.Vector2(int(x + 32), int(cr.y + cr.height / 2 - ls.y / 2)), 42, 0,
rl.Color(240, 180, 180, 255))
self._tap_targets.append((cr, self._cancel_nav))
y += ROW_HEIGHT + SPACING
# Home / Work
hw_gap = ROW_GAP
hw_w = (w - hw_gap) / 2
self._row(rl.Rectangle(x, y, hw_w, ROW_HEIGHT), self._home_icon, tr("Home"),
self._home.address if self._home else tr("Set home address"), self._on_home,
on_delete=(self._remove_home if self._home else None))
self._row(rl.Rectangle(x + hw_w + hw_gap, y, hw_w, ROW_HEIGHT), self._work_icon, tr("Work"),
self._work.address if self._work else tr("Set work address"), self._on_work,
on_delete=(self._remove_work if self._work else None))
y += ROW_HEIGHT + SPACING
# Recents (fit as many as room allows, leaving space for the map)
map_min = 300
for r in self._recents:
if y + ROW_HEIGHT > rect.y + rect.height - map_min - SPACING:
break
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._recent_icon, r.name, r.address,
(lambda r=r: self._navigate_place(r)), on_delete=(lambda r=r: self._remove_recent(r)))
y += ROW_HEIGHT + ROW_GAP
# Map preview of current location
map_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
if map_rect.height > 120:
self._imap.render(map_rect)
if self._status_msg and time.monotonic() - self._status_ts < 2.5:
self._draw_toast(map_rect, self._status_msg)
def _draw_toast(self, area: rl.Rectangle, text: str):
font = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(font, text, 36)
pad = 32
pw = ts.x + pad * 2
pill = rl.Rectangle(area.x + (area.width - pw) / 2, area.y + 24, pw, 66)
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(20, 22, 26, 235))
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, PANEL_BORDER)
rl.draw_text_ex(font, text, rl.Vector2(int(pill.x + pad), int(pill.y + 33 - ts.y / 2)), 36, 0, rl.WHITE)
def _render_map(self, rect: rl.Rectangle):
lat, lon, bearing, fix = current_or_last_gps_position()
if fix and self._map.has_token:
self._map.request(lat, lon, 0.0, rect.width, rect.height)
if self._map.draw(rect, roundness=0.03):
# The static map is centered on the fix, so the current location is the panel center.
cx, cy = int(rect.x + rect.width / 2), int(rect.y + rect.height / 2)
rl.draw_circle(cx, cy, 26, rl.Color(255, 255, 255, 235))
rl.draw_circle(cx, cy, 18, rl.Color(23, 134, 246, 255)) # blue location dot
return
rl.draw_rectangle_rounded(rect, 0.03, 20, rl.Color(18, 18, 20, 255))
rl.draw_rectangle_rounded_lines_ex(rect, 0.03, 20, 2, PANEL_BORDER)
pin_x = int(rect.x + (rect.width - self._pin_icon.width) / 2)
rl.draw_texture(self._pin_icon, pin_x, int(rect.y + rect.height / 2 - self._pin_icon.height), MUTED)
self._draw_center_note(rect, tr("Waiting for GPS fix..."), dy=16)
def _draw_center_note(self, rect: rl.Rectangle, text: str, dy: float = 0):
font = gui_app.font(FontWeight.MEDIUM)
ns = measure_text_cached(font, text, 40)
rl.draw_text_ex(font, text, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2),
int(rect.y + rect.height / 2 + dy)), 40, 0, MUTED)
def _render_results(self, rect: rl.Rectangle):
font = gui_app.font(FontWeight.MEDIUM)
x, w = rect.x, rect.width
y = rect.y
# Query bar — tap to reopen the keyboard and refine the search.
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
cy = bar.y + bar.height / 2
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
rl.draw_text_ex(font, self._query or tr("Search"),
rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24), int(cy - 22)), 44, 0, rl.WHITE)
self._tap_targets.append((bar, lambda: self._open_search(self._purpose)))
y += SEARCH_HEIGHT + SPACING
list_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
results = self._search.results()
if self._selecting:
self._draw_center_note(list_rect, self._status_msg or tr("Locating..."))
return
if not results:
note = tr("Searching...") if self._search.searching else (self._status_msg or tr("No results"))
self._draw_center_note(list_rect, note)
return
for r in results:
if y + ROW_HEIGHT > list_rect.y + list_rect.height:
break
dist = f"{r.distance_m / 1609.34:.1f} mi" if r.distance_m else ""
sub = f"{r.address} · {dist}" if dist else r.address
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._pin_icon, r.name, sub,
(lambda r=r: self._select_result(r)))
y += ROW_HEIGHT + ROW_GAP
def _handle_mouse_release(self, mouse_pos: MousePos):
for rect, cb in self._tap_targets:
if rl.check_collision_point_rec(mouse_pos, rect):
cb()
return

View File

@@ -0,0 +1,113 @@
from enum import IntEnum
import pyray as rl
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.button import Button, ButtonStyle
from iqpilot.system.ui.widgets.label import Label
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.version import terms_version
DEBUG = False
class OnboardingState(IntEnum):
TERMS = 0
DECLINE = 1
class TermsPage(Widget):
def __init__(self, on_accept=None, on_decline=None):
super().__init__()
self._on_accept = on_accept
self._on_decline = on_decline
self._title = Label(tr("Welcome to IQ.Pilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._desc = Label(tr("You must accept the Terms of Service to use IQ.Pilot. Read the latest terms before continuing at https://iqlvbs.com/tos."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
def _render(self, _):
welcome_x = self._rect.x + 95
welcome_y = self._rect.y + 165
welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90)
self._title.render(welcome_rect)
desc_x = welcome_x
# TODO: Label doesn't top align when wrapping
desc_y = welcome_y - 100
desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250)
self._desc.render(desc_rect)
btn_y = self._rect.y + self._rect.height - 160 - 45
btn_width = (self._rect.width - 45 * 3) / 2
self._decline_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
self._accept_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
if DEBUG:
rl.draw_rectangle_lines_ex(welcome_rect, 3, rl.RED)
rl.draw_rectangle_lines_ex(desc_rect, 3, rl.RED)
return -1
class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._text = Label(tr("You must accept the Terms of Service in order to use IQ.Pilot."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._back_btn = Button(tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(tr("Decline, uninstall IQ.Pilot"), button_style=ButtonStyle.DANGER,
click_callback=self._on_uninstall_clicked)
def _on_uninstall_clicked(self):
ui_state.params.put_bool("DoUninstall", True)
gui_app.request_close()
def _render(self, _):
btn_y = self._rect.y + self._rect.height - 160 - 45
btn_width = (self._rect.width - 45 * 3) / 2
self._back_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160))
self._uninstall_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160))
# text rect in middle of top and button
text_height = btn_y - (200 + 45)
text_rect = rl.Rectangle(self._rect.x + 165, self._rect.y + (btn_y - text_height) / 2 + 10, self._rect.width - (165 * 2), text_height)
if DEBUG:
rl.draw_rectangle_lines_ex(text_rect, 3, rl.RED)
self._text.render(text_rect)
class OnboardingWindow(Widget):
def __init__(self):
super().__init__()
self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
self._state = OnboardingState.TERMS
self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
self._decline_page = DeclinePage(back_callback=self._on_decline_back)
@property
def completed(self) -> bool:
return self._accepted_terms
def _on_terms_declined(self):
self._state = OnboardingState.DECLINE
def _on_decline_back(self):
self._state = OnboardingState.TERMS
def _on_terms_accepted(self):
ui_state.params.put("HasAcceptedTerms", terms_version)
self._accepted_terms = True
gui_app.set_modal_overlay(None)
def _render(self, _):
if self._state == OnboardingState.TERMS:
self._terms.render(self._rect)
elif self._state == OnboardingState.DECLINE:
self._decline_page.render(self._rect)
return -1

View File

@@ -0,0 +1,194 @@
import threading
import pyray as rl
from collections.abc import Callable
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.selfdrive.ui.lib.local_routes import list_local_routes, CAMERA_LABELS, format_local_time
from iqpilot.selfdrive.ui.lib import cloud_routes_shim as cloud
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.lib.scroll_panel import GuiScrollPanel
from iqpilot.system.ui.widgets import Widget
MARGIN = 40
SPACING = 25
ROW_HEIGHT = 156
ROW_GAP = 22
BTN_SIZE = 120
ROW_BG = rl.Color(38, 40, 46, 255)
ROW_BORDER = rl.Color(255, 255, 255, 26)
PLAY_BG = rl.Color(16, 185, 169, 255)
SUBTEXT = rl.Color(158, 162, 170, 255)
# Upload-status badge colors.
BADGE_UPLOADED = rl.Color(16, 185, 129, 255)
BADGE_UPLOADING = rl.Color(234, 179, 8, 255)
BADGE_CLOUD = rl.Color(96, 132, 214, 255)
BADGE_LOCAL = rl.Color(90, 96, 108, 255)
BADGE_TEXT = rl.Color(10, 14, 18, 255)
class RoutesLayout(Widget):
"""Offroad Routes screen: local recorded routes + konn3kt cloud upload status / cloud-only routes."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Routes")))
self._play_icon = gui_app.texture("icons/iq/play.png", 56, 56, keep_aspect_ratio=True)
self._on_play: Callable[[str], None] | None = None
self._scroll_panel = GuiScrollPanel()
self._entries: list = []
self._row_hitboxes: list[tuple[rl.Rectangle, object]] = []
self._cloud_thread: threading.Thread | None = None
self._cloud_generation = 0
def set_on_back(self, cb: Callable[[], None]) -> None:
self._header.set_on_back(cb)
def set_on_play(self, cb: Callable[[str], None]) -> None:
self._on_play = cb
def show_event(self):
super().show_event()
local_routes = list_local_routes()
# Show local routes immediately, then fold in cloud upload status / cloud-only routes async.
self._entries = cloud.merge_routes(local_routes, [])
self._start_cloud_fetch(local_routes)
def _start_cloud_fetch(self, local_routes: list) -> None:
if not cloud.cloud_available():
return
self._cloud_generation += 1
generation = self._cloud_generation
def _worker():
dongle_id = cloud.get_dongle_id()
cloud_routes = cloud.list_cloud_routes(dongle_id) if dongle_id else []
if generation == self._cloud_generation:
self._entries = cloud.merge_routes(local_routes, cloud_routes)
self._cloud_thread = threading.Thread(target=_worker, daemon=True)
self._cloud_thread.start()
@staticmethod
def _entry_title(entry) -> str:
if entry.local is not None:
return entry.local.label
if entry.cloud is not None and entry.cloud.start_time > 0:
return format_local_time(entry.cloud.start_time)
return entry.name
@staticmethod
def _fmt_duration(seconds: float) -> str:
s = max(0, int(round(seconds)))
h, rem = divmod(s, 3600)
m, sec = divmod(rem, 60)
return f"{h}h {m:02d}m" if h else f"{m}:{sec:02d}"
def _entry_subtitle_parts(self, entry) -> list[str]:
if entry.local is not None:
parts = [self._fmt_duration(entry.local.duration_s)]
cams = ", ".join(CAMERA_LABELS.get(c, c).replace(" Cam", "") for c in entry.local.cameras)
if cams:
parts.append(cams)
return parts
if entry.cloud is not None:
parts = []
if entry.cloud.length_miles > 0:
parts.append(f"{entry.cloud.length_miles:.1f} mi")
parts.append(tr("Cloud only"))
return parts
return []
@staticmethod
def _draw_dotted(font, parts: list[str], x: float, y: float, size: int, color) -> None:
cx = x
for i, part in enumerate(parts):
if i > 0:
cx += 10
rl.draw_circle(int(cx), int(y + size / 2), 3, rl.Color(color.r, color.g, color.b, 150))
cx += 16
rl.draw_text_ex(font, part, rl.Vector2(int(cx), int(y)), size, 0, color)
cx += measure_text_cached(font, part, size).x
def _render(self, rect: rl.Rectangle):
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
x = rect.x + MARGIN
w = rect.width - 2 * MARGIN
list_top = header_rect.y + HEADER_HEIGHT + SPACING
list_rect = rl.Rectangle(x, list_top, w, rect.y + rect.height - list_top - MARGIN)
self._row_hitboxes = []
font = gui_app.font(FontWeight.MEDIUM)
if not self._entries:
note = tr("No routes recorded")
ns = measure_text_cached(font, note, 44)
rl.draw_text_ex(font, note, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2), int(list_top + 80)), 44, 0,
rl.Color(150, 150, 155, 255))
return
row_stride = ROW_HEIGHT + ROW_GAP
content_rect = rl.Rectangle(list_rect.x, list_rect.y, list_rect.width, len(self._entries) * row_stride)
offset = self._scroll_panel.update(list_rect, content_rect)
rl.begin_scissor_mode(int(list_rect.x), int(list_rect.y), int(list_rect.width), int(list_rect.height))
for i, entry in enumerate(self._entries):
ry = list_rect.y + i * row_stride + offset
row = rl.Rectangle(x, ry, w, ROW_HEIGHT)
if not rl.check_collision_recs(row, list_rect):
continue
rl.draw_rectangle_rounded(row, 0.3, 20, ROW_BG)
rl.draw_rectangle_rounded_lines_ex(row, 0.3, 20, 2, ROW_BORDER)
title_size = 46
subtitle_size = 30
title = self._entry_title(entry)
subtitle_parts = self._entry_subtitle_parts(entry)
title_ts = measure_text_cached(font, title, title_size)
text_y = ry + (ROW_HEIGHT - title_ts.y - subtitle_size - 10) / 2
rl.draw_text_ex(font, title, rl.Vector2(int(x + 40), int(text_y)), title_size, 0, rl.WHITE)
self._draw_dotted(font, subtitle_parts, x + 40, text_y + title_ts.y + 10, subtitle_size, SUBTEXT)
cy = ry + ROW_HEIGHT / 2
self._draw_status_badge(entry, x + w - 40 - BTN_SIZE - 24, cy, font)
play_rect = rl.Rectangle(x + w - 40 - BTN_SIZE, cy - BTN_SIZE / 2, BTN_SIZE, BTN_SIZE)
rl.draw_circle(int(play_rect.x + BTN_SIZE / 2), int(cy), BTN_SIZE / 2, PLAY_BG)
rl.draw_texture(self._play_icon, int(play_rect.x + (BTN_SIZE - self._play_icon.width) / 2 + 4),
int(cy - self._play_icon.height / 2), rl.Color(8, 16, 16, 255))
self._row_hitboxes.append((play_rect, entry))
rl.end_scissor_mode()
def _draw_status_badge(self, entry, right_x: float, cy: float, font) -> None:
if entry.is_local and entry.upload_state == cloud.UPLOAD_UPLOADED:
label, color = tr("Uploaded"), BADGE_UPLOADED
elif entry.is_local and entry.upload_state == cloud.UPLOAD_UPLOADING:
label, color = tr("Uploading"), BADGE_UPLOADING
elif not entry.is_local and entry.is_cloud:
label, color = tr("Cloud"), BADGE_CLOUD
elif entry.is_local:
label, color = tr("On device"), BADGE_LOCAL
else:
return
fs = 24
pad = 18
tw = measure_text_cached(font, label, fs).x + pad * 2
badge = rl.Rectangle(right_x - tw, cy - 20, tw, 40)
rl.draw_rectangle_rounded(badge, 0.5, 12, color)
rl.draw_text_ex(font, label, rl.Vector2(int(badge.x + pad), int(cy - fs / 2)), fs, 0, BADGE_TEXT)
def _handle_mouse_release(self, mouse_pos: MousePos):
if not self._scroll_panel.is_touch_valid():
return
for play_rect, entry in self._row_hitboxes:
if rl.check_collision_point_rec(mouse_pos, play_rect):
if self._on_play:
self._on_play(entry.name)
return

View File

@@ -0,0 +1,9 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
"""
from iqpilot.selfdrive.ui.ui_state import ui_state
def restart_needed_callback(_=None):
ui_state.params.put_bool("OnroadCycleRequested", True)

View File

@@ -0,0 +1,155 @@
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.hardware.tici.usb_storage import apply_usb_storage_state
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.list_view import toggle_item
from iqpilot.system.ui.widgets.scroller_tici import Scroller
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr, tr_noop
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item
# Description constants
DESCRIPTIONS = {
'enable_adb': tr_noop(
"ADB (Android Debug Bridge) allows connecting to your device over USB or over the network."
),
'ssh_key': tr_noop(
"Warning: This grants SSH access to all public keys in your GitHub settings. Never enter a GitHub username " +
"other than your own. An IQ.Pilot employee will NEVER ask you to add their GitHub username."
),
'usb_storage': tr_noop(
"Exposes a snapshot of recent dashcam clips and logs as a USB drive when connected to a computer. " +
"IQ.Pilot keeps running while this is enabled."
),
'long_maneuver': tr_noop(
"Commands a scripted sequence of acceleration steps to measure longitudinal actuator response. " +
"Requires IQ.Pilot longitudinal control. Only use on a clear, closed road."
),
'lat_maneuver': tr_noop(
"Commands a scripted sequence of lateral acceleration steps to measure steering actuator response. " +
"Only use on a straight, flat, clear road."
),
}
class DeveloperLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._is_release = self._params.get_bool("IsReleaseBranch")
# Build items and keep references for callbacks/state updates
self._adb_toggle = toggle_item(
lambda: tr("Enable ADB"),
description=lambda: tr(DESCRIPTIONS["enable_adb"]),
initial_state=self._params.get_bool("AdbEnabled"),
callback=self._on_enable_adb,
enabled=ui_state.is_offroad,
)
self._usb_storage_toggle = toggle_item(
lambda: tr("USB Storage"),
description=lambda: tr(DESCRIPTIONS["usb_storage"]),
initial_state=self._params.get_bool("UsbStorageEnabled"),
callback=self._on_enable_usb_storage,
enabled=ui_state.is_offroad,
)
# SSH enable toggle + SSH key management
self._ssh_toggle = toggle_item(
lambda: tr("Enable SSH"),
description="",
initial_state=self._params.get_bool("SshEnabled"),
callback=self._on_enable_ssh,
)
self._ssh_keys = ssh_key_item(lambda: tr("SSH Keys"), description=lambda: tr(DESCRIPTIONS["ssh_key"]))
self._long_maneuver_toggle = toggle_item(
lambda: tr("Longitudinal Maneuver Mode"),
description=lambda: tr(DESCRIPTIONS["long_maneuver"]),
initial_state=self._params.get_bool("LongitudinalManeuverMode"),
callback=self._on_long_maneuver_mode,
)
self._lat_maneuver_toggle = toggle_item(
lambda: tr("Lateral Maneuver Mode"),
description=lambda: tr(DESCRIPTIONS["lat_maneuver"]),
initial_state=self._params.get_bool("LateralManeuverMode"),
callback=self._on_lat_maneuver_mode,
)
self._on_enable_ui_debug(self._params.get_bool("ShowDebugInfo"))
self._scroller = Scroller([
self._adb_toggle,
self._usb_storage_toggle,
self._ssh_toggle,
self._ssh_keys,
self._long_maneuver_toggle,
self._lat_maneuver_toggle,
], line_separator=True, spacing=0)
# Toggles should be not available to change in onroad state
ui_state.add_offroad_transition_callback(self._update_toggles)
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
for item in (self._long_maneuver_toggle, self._lat_maneuver_toggle):
item.set_visible(not self._is_release)
if ui_state.CP is not None:
self._long_maneuver_toggle.action_item.set_enabled(ui_state.has_longitudinal_control and ui_state.is_offroad())
self._lat_maneuver_toggle.action_item.set_enabled(ui_state.is_offroad())
else:
self._long_maneuver_toggle.action_item.set_enabled(False)
self._lat_maneuver_toggle.action_item.set_enabled(False)
# TODO: make a param control list item so we don't need to manage internal state as much here
# refresh toggles from params to mirror external changes
for key, item in (
("AdbEnabled", self._adb_toggle),
("UsbStorageEnabled", self._usb_storage_toggle),
("SshEnabled", self._ssh_toggle),
("LongitudinalManeuverMode", self._long_maneuver_toggle),
("LateralManeuverMode", self._lat_maneuver_toggle),
):
item.action_item.set_state(self._params.get_bool(key))
def _on_enable_ui_debug(self, state: bool):
self._params.put_bool("ShowDebugInfo", state)
gui_app.set_show_touches(state)
gui_app.set_show_fps(state)
gui_app.set_show_mouse_coords(state)
def _on_enable_adb(self, state: bool):
self._params.put_bool("AdbEnabled", state)
def _on_enable_usb_storage(self, state: bool):
apply_usb_storage_state(state)
def _on_enable_ssh(self, state: bool):
self._params.put_bool("SshEnabled", state)
def _on_long_maneuver_mode(self, state: bool):
self._params.put_bool("LongitudinalManeuverMode", state)
self._params.put_bool("JoystickDebugMode", False)
self._params.put_bool("LateralManeuverMode", False)
self._lat_maneuver_toggle.action_item.set_state(False)
def _on_lat_maneuver_mode(self, state: bool):
self._params.put_bool("LateralManeuverMode", state)
self._params.put_bool("JoystickDebugMode", False)
self._params.put_bool("ExperimentalMode", False)
self._params.put_bool("LongitudinalManeuverMode", False)
self._long_maneuver_toggle.action_item.set_state(False)

View File

@@ -0,0 +1,201 @@
import os
import math
from iqpilot.cereal import messaging, log
from iqpilot.common.basedir import BASEDIR
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog
from iqpilot.konn3kt.registration import get_cached_dongle_id
from iqpilot.system.hardware import TICI
from iqpilot.system.ui.lib.application import FontWeight, gui_app
from iqpilot.system.ui.lib.multilang import multilang, tr, tr_noop
from iqpilot.system.ui.widgets import Widget, DialogResult
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from iqpilot.system.ui.widgets.html_render import HtmlModal
from iqpilot.system.ui.widgets.list_view import text_item, button_item, dual_button_item
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from iqpilot.system.ui.widgets.scroller_tici import Scroller
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
# Description constants
DESCRIPTIONS = {
'pair_device': tr_noop("Pair your device in the Konn3kt app."),
'driver_camera': tr_noop("Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)"),
'reset_calibration': tr_noop("IQ.Pilot requires the device to be mounted within 4° left or right and within 5° up or 9° down."),
}
class DeviceLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._select_language_dialog: MultiOptionDialog | None = None
self._driver_camera: DriverCameraDialog | None = None
self._pair_device_dialog: PairingDialog | None = None
self._fcc_dialog: HtmlModal | None = None
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=True, spacing=0)
ui_state.add_offroad_transition_callback(self._offroad_transition)
def _initialize_items(self):
self._pair_device_btn = button_item(lambda: tr("Pair Device"), lambda: tr("PAIR"), lambda: tr(DESCRIPTIONS['pair_device']), callback=self._pair_device)
self._pair_device_btn.set_visible(lambda: not ui_state.prime_state.is_paired())
self._reset_calib_btn = button_item(lambda: tr("Reset Calibration"), lambda: tr("RESET"), lambda: tr(DESCRIPTIONS['reset_calibration']),
callback=self._reset_calibration_prompt)
self._reset_calib_btn.set_description_opened_callback(self._update_calib_description)
self._power_off_btn = dual_button_item(lambda: tr("Reboot"), lambda: tr("Power Off"),
left_callback=self._reboot_prompt, right_callback=self._power_off_prompt)
items = [
text_item(lambda: tr("Dongle ID"), lambda: get_cached_dongle_id(self._params, prefer_readonly=True) or tr("N/A")),
text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))),
self._pair_device_btn,
button_item(lambda: tr("Driver Camera"), lambda: tr("PREVIEW"), lambda: tr(DESCRIPTIONS['driver_camera']),
callback=self._show_driver_camera, enabled=ui_state.is_offroad),
self._reset_calib_btn,
regulatory_btn := button_item(lambda: tr("Regulatory"), lambda: tr("VIEW"), callback=self._on_regulatory, enabled=ui_state.is_offroad),
button_item(lambda: tr("Change Language"), lambda: tr("CHANGE"), callback=self._show_language_dialog),
self._power_off_btn,
]
regulatory_btn.set_visible(TICI)
return items
def _offroad_transition(self):
self._power_off_btn.action_item.right_button.set_visible(ui_state.is_offroad())
def show_event(self):
self._scroller.show_event()
def _render(self, rect):
self._scroller.render(rect)
def _show_language_dialog(self):
def handle_language_selection(result: int):
if result == 1 and self._select_language_dialog:
selected_language = multilang.languages[self._select_language_dialog.selection]
multilang.change_language(selected_language)
self._update_calib_description()
self._select_language_dialog = None
self._select_language_dialog = MultiOptionDialog(tr("Select a language"), multilang.languages, multilang.codes[multilang.language],
option_font_weight=FontWeight.UNIFONT)
gui_app.set_modal_overlay(self._select_language_dialog, callback=handle_language_selection)
def _show_driver_camera(self):
if not self._driver_camera:
self._driver_camera = DriverCameraDialog()
gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None))
def _reset_calibration_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reset Calibration")))
return
def reset_calibration(result: int):
# Check engaged again in case it changed while the dialog was open
if ui_state.engaged or result != DialogResult.CONFIRM:
return
self._params.remove("CalibrationParams")
self._params.remove("LiveTorqueParameters")
self._params.remove("LiveParameters")
self._params.remove("LiveParametersV2")
self._params.remove("LiveDelay")
self._params.put_bool("OnroadCycleRequested", True)
self._update_calib_description()
dialog = ConfirmDialog(tr("Are you sure you want to reset calibration?"), tr("Reset"))
gui_app.set_modal_overlay(dialog, callback=reset_calibration)
def _update_calib_description(self):
desc = tr(DESCRIPTIONS['reset_calibration'])
calib_bytes = self._params.get("CalibrationParams")
if calib_bytes:
try:
calib = messaging.log_from_bytes(calib_bytes, log.Event).extrinsicsCalibration
if calib.calStatus != log.ExtrinsicsCalibration.Status.uncalibrated:
pitch = math.degrees(calib.rpyCalib[1])
yaw = math.degrees(calib.rpyCalib[2])
desc += tr(" Your device is pointed {:.1f}° {} and {:.1f}° {}.").format(abs(pitch), tr("down") if pitch > 0 else tr("up"),
abs(yaw), tr("left") if yaw > 0 else tr("right"))
except Exception:
cloudlog.exception("invalid CalibrationParams")
lag_perc = 0
lag_bytes = self._params.get("LiveDelay")
if lag_bytes:
try:
lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).lateralDelay.calPerc
except Exception:
cloudlog.exception("invalid LiveDelay")
if lag_perc < 100:
desc += tr("<br><br>Steering lag calibration is {}% complete.").format(lag_perc)
else:
desc += tr("<br><br>Steering lag calibration is complete.")
torque_bytes = self._params.get("LiveTorqueParameters")
if torque_bytes:
try:
torque = messaging.log_from_bytes(torque_bytes, log.Event).lateralTorqueParameters
# don't add for non-torque cars
if torque.useParams:
torque_perc = torque.calPerc
if torque_perc < 100:
desc += tr(" Steering torque response calibration is {}% complete.").format(torque_perc)
else:
desc += tr(" Steering torque response calibration is complete.")
except Exception:
cloudlog.exception("invalid LiveTorqueParameters")
desc += "<br><br>"
desc += tr("IQ.Pilot is continuously calibrating, resetting is rarely required. " +
"Resetting calibration will restart IQ.Pilot if the car is powered on.")
self._reset_calib_btn.set_description(desc)
def _reboot_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
return
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
def _perform_reboot(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoReboot", True)
def _power_off_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
return
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
def _perform_power_off(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoShutdown", True)
def _pair_device(self):
if not self._pair_device_dialog:
self._pair_device_dialog = PairingDialog()
gui_app.set_modal_overlay(self._pair_device_dialog, callback=lambda result: setattr(self, '_pair_device_dialog', None))
def _on_regulatory(self):
if not self._fcc_dialog:
self._fcc_dialog = HtmlModal(os.path.join(BASEDIR, "iqpilot/selfdrive/assets/offroad/fcc.html"))
gui_app.set_modal_overlay(self._fcc_dialog)

View File

@@ -0,0 +1,170 @@
import pyray as rl
from dataclasses import dataclass
from enum import IntEnum
from collections.abc import Callable
from iqpilot.selfdrive.ui.layouts.settings.developer import DeveloperLayout
from iqpilot.selfdrive.ui.layouts.settings.device import DeviceLayout
from iqpilot.selfdrive.ui.layouts.settings.software import SoftwareLayout
from iqpilot.selfdrive.ui.layouts.settings.toggles import TogglesLayout
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr, tr_noop
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.lib.wifi_manager import WifiManager
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.network import NetworkUI
# Constants
SIDEBAR_WIDTH = 500
CLOSE_BTN_SIZE = 200
CLOSE_ICON_SIZE = 70
NAV_BTN_HEIGHT = 110
PANEL_MARGIN = 50
# Colors
SIDEBAR_COLOR = rl.BLACK
PANEL_COLOR = rl.Color(41, 41, 41, 255)
CLOSE_BTN_COLOR = rl.Color(41, 41, 41, 255)
CLOSE_BTN_PRESSED = rl.Color(59, 59, 59, 255)
TEXT_NORMAL = rl.Color(128, 128, 128, 255)
TEXT_SELECTED = rl.WHITE
class PanelType(IntEnum):
DEVICE = 0
NETWORK = 1
TOGGLES = 2
SOFTWARE = 3
DEVELOPER = 5
@dataclass
class PanelInfo:
name: str
instance: Widget
button_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
class SettingsLayout(Widget):
def __init__(self):
super().__init__()
self._current_panel = PanelType.DEVICE
# Panel configuration
wifi_manager = WifiManager()
wifi_manager.set_active(False)
self._panels = {
PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayout()),
PanelType.NETWORK: PanelInfo(tr_noop("Network"), NetworkUI(wifi_manager)),
PanelType.TOGGLES: PanelInfo(tr_noop("Toggles"), TogglesLayout()),
PanelType.SOFTWARE: PanelInfo(tr_noop("Software"), SoftwareLayout()),
PanelType.DEVELOPER: PanelInfo(tr_noop("Developer"), DeveloperLayout()),
}
self._font_medium = gui_app.font(FontWeight.MEDIUM)
self._close_icon = gui_app.texture("icons/close2.png", CLOSE_ICON_SIZE, CLOSE_ICON_SIZE)
# Callbacks
self._close_callback: Callable | None = None
def set_callbacks(self, on_close: Callable):
self._close_callback = on_close
def _render(self, rect: rl.Rectangle):
# Calculate layout
sidebar_rect = rl.Rectangle(rect.x, rect.y, SIDEBAR_WIDTH, rect.height)
panel_rect = rl.Rectangle(rect.x + SIDEBAR_WIDTH, rect.y, rect.width - SIDEBAR_WIDTH, rect.height)
# Draw components
self._draw_sidebar(sidebar_rect)
self._draw_current_panel(panel_rect)
def _draw_sidebar(self, rect: rl.Rectangle):
rl.draw_rectangle_rec(rect, SIDEBAR_COLOR)
# Close button
close_btn_rect = rl.Rectangle(
rect.x + (rect.width - CLOSE_BTN_SIZE) / 2, rect.y + 60, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE
)
pressed = (rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and
rl.check_collision_point_rec(rl.get_mouse_position(), close_btn_rect))
close_color = CLOSE_BTN_PRESSED if pressed else CLOSE_BTN_COLOR
rl.draw_rectangle_rounded(close_btn_rect, 1.0, 20, close_color)
icon_color = rl.Color(255, 255, 255, 255) if not pressed else rl.Color(220, 220, 220, 255)
icon_dest = rl.Rectangle(
close_btn_rect.x + (close_btn_rect.width - self._close_icon.width) / 2,
close_btn_rect.y + (close_btn_rect.height - self._close_icon.height) / 2,
self._close_icon.width,
self._close_icon.height,
)
rl.draw_texture_pro(
self._close_icon,
rl.Rectangle(0, 0, self._close_icon.width, self._close_icon.height),
icon_dest,
rl.Vector2(0, 0),
0,
icon_color,
)
# Store close button rect for click detection
self._close_btn_rect = close_btn_rect
# Navigation buttons
y = rect.y + 300
for panel_type, panel_info in self._panels.items():
button_rect = rl.Rectangle(rect.x + 50, y, rect.width - 150, NAV_BTN_HEIGHT)
# Button styling
is_selected = panel_type == self._current_panel
text_color = TEXT_SELECTED if is_selected else TEXT_NORMAL
# Draw button text (right-aligned)
panel_name = tr(panel_info.name)
text_size = measure_text_cached(self._font_medium, panel_name, 65)
text_pos = rl.Vector2(
button_rect.x + button_rect.width - text_size.x, button_rect.y + (button_rect.height - text_size.y) / 2
)
rl.draw_text_ex(self._font_medium, panel_name, text_pos, 65, 0, text_color)
# Store button rect for click detection
panel_info.button_rect = button_rect
y += NAV_BTN_HEIGHT
def _draw_current_panel(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(
rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20), 0.04, 30, PANEL_COLOR
)
content_rect = rl.Rectangle(rect.x + PANEL_MARGIN, rect.y + 25, rect.width - (PANEL_MARGIN * 2), rect.height - 50)
# rl.draw_rectangle_rounded(content_rect, 0.03, 30, PANEL_COLOR)
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(content_rect)
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
# Check close button
if rl.check_collision_point_rec(mouse_pos, self._close_btn_rect):
if self._close_callback:
self._close_callback()
return
# Check navigation buttons
for panel_type, panel_info in self._panels.items():
if rl.check_collision_point_rec(mouse_pos, panel_info.button_rect):
self.set_current_panel(panel_type)
return
def set_current_panel(self, panel_type: PanelType):
if panel_type != self._current_panel:
self._panels[self._current_panel].instance.hide_event()
self._current_panel = panel_type
self._panels[self._current_panel].instance.show_event()
def show_event(self):
super().show_event()
self._panels[self._current_panel].instance.show_event()
def hide_event(self):
super().hide_event()
self._panels[self._current_panel].instance.hide_event()

View File

@@ -0,0 +1,262 @@
import os
import time
import datetime
from iqpilot.common.time_helpers import system_time_valid
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr, trn
from iqpilot.system.ui.widgets import Widget, DialogResult
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from iqpilot.system.ui.widgets.list_view import button_item, text_item, ListItem
from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from iqpilot.system.ui.widgets.scroller_tici import Scroller
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import button_item
# TODO: remove this. updater fails to respond on startup if time is not correct
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
BRAND_NAME = "IQ.Pilot"
# Mapping updater internal states to translated display strings
STATE_TO_DISPLAY_TEXT = {
"checking...": tr("checking..."),
"downloading...": tr("downloading..."),
"finalizing update...": tr("finalizing update..."),
}
def format_updater_description(description: str | None) -> str:
if not description:
return BRAND_NAME
cleaned = description.strip()
lower = cleaned.lower()
if lower.startswith("iqpilot"):
cleaned = cleaned[len("iqpilot"):].lstrip(" -:/")
if cleaned.lower().startswith(BRAND_NAME.lower()):
return cleaned
return f"{BRAND_NAME} {cleaned}" if cleaned else BRAND_NAME
def time_ago(date: datetime.datetime | None) -> str:
if not date:
return tr("never")
if not system_time_valid():
return date.strftime("%a %b %d %Y")
now = datetime.datetime.now(datetime.UTC)
if date.tzinfo is None:
date = date.replace(tzinfo=datetime.UTC)
diff_seconds = int((now - date).total_seconds())
if diff_seconds < 60:
return tr("now")
if diff_seconds < 3600:
m = diff_seconds // 60
return trn("{} minute ago", "{} minutes ago", m).format(m)
if diff_seconds < 86400:
h = diff_seconds // 3600
return trn("{} hour ago", "{} hours ago", h).format(h)
if diff_seconds < 604800:
d = diff_seconds // 86400
return trn("{} day ago", "{} days ago", d).format(d)
return date.strftime("%a %b %d %Y")
class SoftwareLayout(Widget):
def __init__(self):
super().__init__()
self._onroad_label = ListItem(lambda: tr("Updates are only downloaded while the car is off."))
self._version_item = text_item(lambda: tr("Current Version"), format_updater_description(ui_state.params.get("UpdaterCurrentDescription")))
self._download_btn = button_item(lambda: tr("Download"), lambda: tr("CHECK"), callback=self._on_download_update)
# Install button is initially hidden
self._install_btn = button_item(lambda: tr("Install Update"), lambda: tr("INSTALL"), callback=self._on_install_update)
self._install_btn.set_visible(False)
# Track waiting-for-updater transition to avoid brief re-enable while still idle
self._waiting_for_updater = False
self._waiting_start_ts: float = 0.0
# Branch switcher
self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch)
self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "")
self._branch_dialog: MultiOptionDialog | None = None
# Git auth for private-branch updates (sits with the Target Branch section)
self._auth_btn = button_item(lambda: tr("Git Auth"), lambda: tr("AUTH"), callback=self._on_auth_branch)
self._auth_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self._scroller = Scroller([
self._onroad_label,
self._version_item,
self._download_btn,
self._install_btn,
self._branch_btn,
self._auth_btn,
button_item(lambda: tr("Uninstall"), lambda: tr("UNINSTALL"), callback=self._on_uninstall),
], line_separator=True, spacing=0)
def show_event(self):
self._refresh_auth_value()
self._scroller.show_event()
def _refresh_auth_value(self):
try:
from iqpilot.common.git_creds import has_credentials
configured = has_credentials()
except Exception:
configured = False
self._auth_btn.action_item.set_value(tr("configured") if configured else "")
def _render(self, rect):
self._scroller.render(rect)
def _update_state(self):
# Show/hide onroad warning
self._onroad_label.set_visible(ui_state.is_onroad())
# Update current version and release notes
current_desc = format_updater_description(ui_state.params.get("UpdaterCurrentDescription"))
current_release_notes = (ui_state.params.get("UpdaterCurrentReleaseNotes") or b"").decode("utf-8", "replace")
self._version_item.action_item.set_text(current_desc)
self._version_item.set_description(current_release_notes)
# Update download button visibility and state
self._download_btn.set_visible(ui_state.is_offroad())
updater_state = ui_state.params.get("UpdaterState") or "idle"
failed_count = ui_state.params.get("UpdateFailedCount") or 0
fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable")
update_available = ui_state.params.get_bool("UpdateAvailable")
if updater_state != "idle":
# Updater responded
self._waiting_for_updater = False
self._download_btn.action_item.set_enabled(False)
# Use the mapping, with a fallback to the original state string
display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state)
self._download_btn.action_item.set_value(display_text)
else:
if failed_count > 0:
self._download_btn.action_item.set_value(tr("failed to check for update"))
self._download_btn.action_item.set_text(tr("CHECK"))
elif fetch_available:
self._download_btn.action_item.set_value(tr("update available"))
self._download_btn.action_item.set_text(tr("DOWNLOAD"))
else:
last_update = ui_state.params.get("LastUpdateTime")
if last_update:
formatted = time_ago(last_update)
self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted))
else:
self._download_btn.action_item.set_value(tr("up to date, last checked never"))
self._download_btn.action_item.set_text(tr("CHECK"))
# If we've been waiting too long without a state change, reset state
if self._waiting_for_updater and (time.monotonic() - self._waiting_start_ts > UPDATED_TIMEOUT):
self._waiting_for_updater = False
# Only enable if we're not waiting for updater to flip out of idle
self._download_btn.action_item.set_enabled(not self._waiting_for_updater)
# Update target branch button value
current_branch = ui_state.params.get("UpdaterTargetBranch") or ""
self._branch_btn.action_item.set_value(current_branch)
# Update install button
self._install_btn.set_visible(ui_state.is_offroad() and update_available)
if update_available:
new_desc = format_updater_description(ui_state.params.get("UpdaterNewDescription"))
new_release_notes = (ui_state.params.get("UpdaterNewReleaseNotes") or b"").decode("utf-8", "replace")
self._install_btn.action_item.set_text(tr("INSTALL"))
self._install_btn.action_item.set_value(new_desc)
self._install_btn.set_description(new_release_notes)
# Enable install button for testing (like Qt showEvent)
self._install_btn.action_item.set_enabled(True)
else:
self._install_btn.set_visible(False)
def _on_download_update(self):
# Check if we should start checking or start downloading
self._download_btn.action_item.set_enabled(False)
if self._download_btn.action_item.text == tr("CHECK"):
# Start checking for updates
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
os.system("pkill -SIGUSR1 -f system.updated.updated")
else:
# Start downloading
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
os.system("pkill -SIGHUP -f system.updated.updated")
def _on_uninstall(self):
def handle_uninstall_confirmation(result):
if result == DialogResult.CONFIRM:
ui_state.params.put_bool("DoUninstall", True)
dialog = ConfirmDialog(tr("Are you sure you want to uninstall?"), tr("Uninstall"))
gui_app.set_modal_overlay(dialog, callback=handle_uninstall_confirmation)
def _on_install_update(self):
# Trigger reboot to install update
self._install_btn.action_item.set_enabled(False)
ui_state.params.put_bool("DoReboot", True)
def _on_select_branch(self):
# Get available branches and order
current_git_branch = ui_state.params.get("GitBranch") or ""
branches_str = ui_state.params.get("UpdaterAvailableBranches") or ""
branches = [b for b in branches_str.split(",") if b]
for b in [current_git_branch, "devel-staging", "devel", "nightly", "nightly-dev", "master"]:
if b in branches:
branches.remove(b)
branches.insert(0, b)
current_target = ui_state.params.get("UpdaterTargetBranch") or ""
self._branch_dialog = MultiOptionDialog(tr("Select a branch"), branches, current_target)
def handle_selection(result):
# Confirmed selection
if result == DialogResult.CONFIRM and self._branch_dialog is not None and self._branch_dialog.selection:
selection = self._branch_dialog.selection
ui_state.params.put("UpdaterTargetBranch", selection)
self._branch_btn.action_item.set_value(selection)
os.system("pkill -SIGUSR1 -f system.updated.updated")
self._branch_dialog = None
gui_app.set_modal_overlay(self._branch_dialog, callback=handle_selection)
def _on_auth_branch(self):
# Collect username then token; store encrypted and signal the updater to
# re-check (refreshing the available-branch list for private repos).
from iqpilot.system.ui.iqwidgets.widgets.list_view import open_text_prompt
from iqpilot.common import git_creds
creds = git_creds.get_credentials()
current_user = creds[0] if creds else ""
def on_username(result, username):
if result != DialogResult.CONFIRM:
return
def on_token(token_result, token):
if token_result != DialogResult.CONFIRM:
return
git_creds.set_credentials(username, token)
self._refresh_auth_value()
os.system("pkill -SIGUSR1 -f system.updated.updated")
open_text_prompt(tr("Git token / password"),
tr("leave username and token blank to clear"),
password=True, on_done=on_token)
open_text_prompt(tr("Git username"), tr("for private branch updates"),
initial=current_user, on_done=on_username)

View File

@@ -0,0 +1,343 @@
from iqpilot.cereal import log
from iqpilot.common.params import Params, UnknownKeyName
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
from iqpilot.system.ui.widgets.scroller_tici import Scroller
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from iqpilot.system.ui.lib.application import gui_app
from iqpilot.system.ui.lib.multilang import tr, tr_noop
from iqpilot.system.ui.widgets import DialogResult
from iqpilot.selfdrive.ui.ui_state import ui_state
if gui_app.iqpilot_ui():
from iqpilot.system.ui.iqwidgets.widgets.list_view import toggle_item
from iqpilot.system.ui.iqwidgets.widgets.list_view import multiple_button_item
from iqpilot.ui.layouts.settings.iq_dynamic import IQDynamicLayout
PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
PERSONALITY_DISPLAY_TO_PARAM = [PERSONALITY_TO_INT["relaxed"], PERSONALITY_TO_INT["standard"], PERSONALITY_TO_INT["aggressive"]]
PERSONALITY_PARAM_TO_DISPLAY = {param: idx for idx, param in enumerate(PERSONALITY_DISPLAY_TO_PARAM)}
# Description constants
DESCRIPTIONS = {
"OpenpilotEnabledToggle": tr_noop(
"Use the IQ.Pilot system for adaptive cruise control and lane keep driver assistance. " +
"Your attention is required at all times to use this feature."
),
"DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage IQ.Pilot."),
"LongitudinalPersonality": tr_noop(
"Standard is recommended. In aggressive mode, IQ.Pilot will follow lead cars closer and be more aggressive with the gas and brake. " +
"In relaxed mode IQ.Pilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
"your steering wheel distance button."
),
"IQSpeedAssistMode": tr_noop(
"Controls IQ.Pilot speed limit behavior. Off disables speed limit features, Information only displays limits, Warning highlights overspeed, and Control adjusts set speed using detected limits."
),
"IsLdwEnabled": tr_noop(
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
"without a turn signal activated while driving over 31 mph (50 km/h)."
),
"AlwaysOnDM": tr_noop("Enable driver monitoring even when IQ.Pilot is not engaged."),
"DashcamEnabled": tr_noop("Record and upload driving data and video. Disabling this stops all recording! No logs, no video, no audio."),
'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."),
"IsMetric": tr_noop("Display speed in km/h instead of mph."),
"IQAutoUnits": tr_noop(
"Set the units from the device location. Speeds switch to km/h everywhere except the United States, " +
"the United Kingdom and Liberia, and are re-checked when you cross a border."
),
"RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in Konn3kt."),
"LongitudinalControlMode": tr_noop(
"Choose longitudinal behavior: IQ.Pilot (IQ longitudinal + end-to-end), "
"IQ.Dynamic (IQ longitudinal + dynamic mode), IQ.Chill (IQ longitudinal + relaxed personality), "
"or Stock ACC."
),
}
class TogglesLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
# Keep IQ.Pilot enabled by default; the UI no longer exposes this toggle.
self._params.put_bool("OpenpilotEnabledToggle", True)
# param, title, desc, icon, needs_restart
self._toggle_defs = {
"DisengageOnAccelerator": (
lambda: tr("Disengage on Accelerator Pedal"),
DESCRIPTIONS["DisengageOnAccelerator"],
"disengage_on_accelerator.png",
False,
),
"IsLdwEnabled": (
lambda: tr("Enable Lane Departure Warnings"),
DESCRIPTIONS["IsLdwEnabled"],
"warning.png",
False,
),
"DashcamEnabled": (
lambda: tr("Enable Dashcam"),
DESCRIPTIONS["DashcamEnabled"],
"camera.png",
True,
),
"RecordFront": (
lambda: tr("Record and Upload Driver Camera"),
DESCRIPTIONS["RecordFront"],
"monitoring.png",
True,
),
"RecordAudio": (
lambda: tr("Record and Upload Microphone Audio"),
DESCRIPTIONS["RecordAudio"],
"microphone.png",
True,
),
"IsMetric": (
lambda: tr("Use Metric System"),
DESCRIPTIONS["IsMetric"],
"metric.png",
False,
),
"IQAutoUnits": (
lambda: tr("Set Units From Location"),
DESCRIPTIONS["IQAutoUnits"],
"metric.png",
False,
),
}
self._long_personality_setting = multiple_button_item(
lambda: tr("Driving Personality"),
lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]),
buttons=[lambda: tr("Relaxed"), lambda: tr("Standard"), lambda: tr("Aggressive")],
button_width=300,
callback=self._set_longitudinal_personality,
selected_index=PERSONALITY_PARAM_TO_DISPLAY.get(self._params.get("LongitudinalPersonality", return_default=True), 1),
icon="speed_limit.png"
)
self._speed_limit_mode_setting = multiple_button_item(
lambda: tr("Speed Limit"),
lambda: tr(DESCRIPTIONS["IQSpeedAssistMode"]),
buttons=[lambda: tr("Off"), lambda: tr("Info"), lambda: tr("Warning"), lambda: tr("Control")],
button_width=220,
callback=self._set_speed_limit_mode,
selected_index=self._params.get("IQSpeedAssistMode", return_default=True),
icon="speed_limit.png",
)
self._longitudinal_control_mode_setting = multiple_button_item(
lambda: tr("Longitudinal Control"),
lambda: tr(DESCRIPTIONS["LongitudinalControlMode"]),
buttons=[lambda: tr("Stock ACC"), lambda: tr("IQ.Chill"), lambda: tr("IQ.Dynamic"), lambda: tr("IQ.Pilot")],
button_width=250,
callback=self._set_longitudinal_control_mode,
selected_index=self._get_longitudinal_control_mode_index(),
icon="experimental_white.png",
)
self._toggles = {}
self._locked_toggles = set()
self._toggles["LongitudinalControlMode"] = self._longitudinal_control_mode_setting
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
self._toggles["IQSpeedAssistMode"] = self._speed_limit_mode_setting
for param, (title, desc, icon, needs_restart) in self._toggle_defs.items():
initial_state = self._params.get_bool(param)
toggle = toggle_item(
title,
desc,
initial_state,
callback=lambda state, p=param: self._toggle_callback(state, p),
icon=icon,
)
try:
locked = self._params.get_bool(param + "Lock")
except UnknownKeyName:
locked = False
toggle.action_item.set_enabled(not locked)
# Make description callable for live translation
additional_desc = ""
if needs_restart and not locked:
additional_desc = tr("Changing this setting will restart IQ.Pilot if the car is powered on.")
toggle.set_description(lambda og_desc=toggle.description, add_desc=additional_desc: tr(og_desc) + (" " + tr(add_desc) if add_desc else ""))
# track for engaged state updates
if locked:
self._locked_toggles.add(param)
self._toggles[param] = toggle
self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0)
self._iq_dynamic_panel: "IQDynamicLayout | None" = None
self._show_iq_dynamic = False
if gui_app.iqpilot_ui():
self._iq_dynamic_panel = IQDynamicLayout(self._close_iq_dynamic_panel)
ui_state.add_engaged_transition_callback(self._update_toggles)
def _update_state(self):
if ui_state.sm.updated["selfdriveState"]:
personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
if personality != ui_state.personality and ui_state.started:
self._long_personality_setting.action_item.set_selected_button(PERSONALITY_PARAM_TO_DISPLAY.get(personality, 1))
ui_state.personality = personality
self._speed_limit_mode_setting.action_item.set_selected_button(self._params.get("IQSpeedAssistMode", return_default=True))
def _close_iq_dynamic_panel(self):
self._show_iq_dynamic = False
def set_cruise_panel_callback(self, callback: "Callable") -> None:
"""Register callback invoked on double-click of IQ.Dynamic (button index 2)."""
action = self._longitudinal_control_mode_setting.action_item
if hasattr(action, 'set_double_click_callback'):
action.set_double_click_callback(2, callback)
def show_event(self):
self._show_iq_dynamic = False
self._scroller.show_event()
self._update_toggles()
def _update_toggles(self):
ui_state.update_params()
e2e_description = tr(
"Longitudinal Control modes:<br>" +
"IQ.Pilot features are listed below:<br>" +
"<h4>IQ.Pilot End-to-End Longitudinal Control</h4><br>" +
"Let the driving model control the gas and brakes. IQ.Pilot will drive as it thinks a human would, including stopping for red lights and stop signs. " +
"Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This feature is still being improved; " +
"mistakes should be expected.<br>" +
"<h4>IQ.Dynamic</h4><br>" +
"Dynamically blends between adaptive cruise behavior and end-to-end behavior based on scene/context.<br>" +
"<h4>IQ.Chill</h4><br>" +
"Uses standard traffic-aware cruise behavior for longitudinal control.<br>" +
"<h4>New Driving Visualization</h4><br>" +
"The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " +
"The IQ.Pilot logo will also be shown in the top right corner."
)
alpha_available = bool(ui_state.CP is not None and ui_state.CP.alphaLongitudinalAvailable)
alpha_requested = self._params.get_bool("AlphaLongitudinalEnabled")
toyota_stock_long_forced = bool(
ui_state.CP is not None and
ui_state.CP.brand == "toyota" and
self._params.get_bool("IQToyotaFactoryLong")
)
iq_modes_selectable = alpha_available or alpha_requested or toyota_stock_long_forced
availability_note = ""
if ui_state.CP is None:
availability_note = tr("Vehicle longitudinal capability has not been detected yet. Start the car once to detect support.")
elif toyota_stock_long_forced:
availability_note = tr("Factory Toyota longitudinal control is currently enforced. Choose an IQ longitudinal mode to disable it.")
elif not alpha_available:
availability_note = tr("IQ longitudinal modes are unavailable for this vehicle. Stock ACC is the only available option.")
self._toggles["LongitudinalControlMode"].set_visible(True)
self._long_personality_setting.set_visible(True)
mode_index = self._get_longitudinal_control_mode_index()
longitudinal_control_item = self._toggles["LongitudinalControlMode"]
longitudinal_control_item.action_item.set_selected_button(mode_index)
longitudinal_control_item.action_item.set_enabled(not ui_state.engaged)
longitudinal_control_item.action_item.set_enabled_buttons([True, iq_modes_selectable, iq_modes_selectable, iq_modes_selectable])
description = tr(DESCRIPTIONS["LongitudinalControlMode"]) + "<br><br>" + e2e_description
if availability_note:
description += "<br><br><i>" + availability_note + "</i>"
longitudinal_control_item.set_description(description)
personality_enabled = iq_modes_selectable and mode_index in (2, 3)
self._long_personality_setting.action_item.set_enabled(personality_enabled)
# TODO: make a param control list item so we don't need to manage internal state as much here
# refresh toggles from params to mirror external changes
for param in self._toggle_defs:
self._toggles[param].action_item.set_state(self._params.get_bool(param))
# these toggles need restart, block while engaged
for toggle_def in self._toggle_defs:
if self._toggle_defs[toggle_def][3] and toggle_def not in self._locked_toggles:
self._toggles[toggle_def].action_item.set_enabled(not ui_state.engaged)
def _render(self, rect):
if self._show_iq_dynamic and self._iq_dynamic_panel is not None:
self._iq_dynamic_panel.render(rect)
else:
self._scroller.render(rect)
def _get_longitudinal_control_mode_index(self) -> int:
if not self._params.get_bool("AlphaLongitudinalEnabled"):
return 0 # Stock ACC
if not self._params.get_bool("ExperimentalMode"):
return 1 # IQ.Chill
return 2 if self._params.get_bool("IQDynamicMode") else 3 # IQ.Dynamic / IQ.Pilot
def _apply_longitudinal_control_mode(self, button_index: int):
# 0 = Stock ACC, 1 = IQ.Chill, 2 = IQ.Dynamic, 3 = IQ.Pilot
previous_alpha = self._params.get_bool("AlphaLongitudinalEnabled")
previous_toyota_stock_long = self._params.get_bool("IQToyotaFactoryLong")
if button_index == 0:
self._params.put_bool("AlphaLongitudinalEnabled", False)
self._params.put_bool("ExperimentalMode", False)
self._params.put_bool("IQDynamicMode", False)
elif button_index == 1:
self._params.put_bool("AlphaLongitudinalEnabled", True)
self._params.put_bool("ExperimentalMode", False)
self._params.put_bool("IQDynamicMode", False)
self._params.put("LongitudinalPersonality", PERSONALITY_TO_INT["relaxed"])
elif button_index == 2:
self._params.put_bool("AlphaLongitudinalEnabled", True)
self._params.put_bool("ExperimentalMode", True)
self._params.put_bool("IQDynamicMode", True)
else:
self._params.put_bool("AlphaLongitudinalEnabled", True)
self._params.put_bool("ExperimentalMode", True)
self._params.put_bool("IQDynamicMode", False)
if button_index != 0 and previous_toyota_stock_long:
self._params.put_bool("IQToyotaFactoryLong", False)
if previous_alpha != self._params.get_bool("AlphaLongitudinalEnabled") or previous_toyota_stock_long != self._params.get_bool("IQToyotaFactoryLong"):
self._params.put_bool("OnroadCycleRequested", True)
def _toggle_callback(self, state: bool, param: str):
self._params.put_bool(param, state)
if param == "IQAutoUnits" and state:
self._params.remove("IQAutoUnitsRegion")
if self._toggle_defs[param][3]:
self._params.put_bool("OnroadCycleRequested", True)
def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", PERSONALITY_DISPLAY_TO_PARAM[button_index])
def _set_speed_limit_mode(self, button_index: int):
self._params.put("IQSpeedAssistMode", button_index)
def _set_longitudinal_control_mode(self, button_index: int):
# 0 = Stock ACC, 1 = IQ.Chill, 2 = IQ.Dynamic, 3 = IQ.Pilot
if button_index == self._get_longitudinal_control_mode_index():
if button_index == 2 and self._iq_dynamic_panel is not None:
self._show_iq_dynamic = True
return
# IQ.Pilot and IQ.Dynamic both require ExperimentalMode confirmation.
if button_index in (2, 3) and not self._params.get_bool("ExperimentalModeConfirmed"):
def confirm_callback(result: int):
if result == DialogResult.CONFIRM:
self._apply_longitudinal_control_mode(button_index)
self._params.put_bool("ExperimentalModeConfirmed", True)
else:
self._toggles["LongitudinalControlMode"].action_item.set_selected_button(self._get_longitudinal_control_mode_index())
self._update_toggles()
content = (f"<h1>{self._toggles['LongitudinalControlMode'].title}</h1><br>" +
f"<p>{self._toggles['LongitudinalControlMode'].description}</p>")
dlg = ConfirmDialog(content, tr("Enable"), rich=True)
gui_app.set_modal_overlay(dlg, callback=confirm_callback)
else:
self._apply_longitudinal_control_mode(button_index)
self._update_toggles()

View File

@@ -0,0 +1,557 @@
import pyray as rl
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
from iqpilot.common.params import Params
from iqpilot.ui.layouts.settings.iq_panels import IQSettingsLayout
from iqpilot.selfdrive.ui.layouts.home import _format_updater_description
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT, BACK_BTN_SIZE
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, 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, DialogResult
from iqpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
from iqpilot.system.ui.widgets.label import UnifiedLabel
# iOS-style swipe-from-the-left-edge-to-go-back, for panel -> grid only (the one place
# this layout already has a from/to slide transition to reuse for the live drag).
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back touch must start within
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to "this is a swipe"
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (while still under arm distance) that cancels it
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of screen width dragged to complete the pop on release
SWIPE_SETTLE_SECONDS = 0.16 # glide from the release point to done/cancelled (no snap)
MARGIN = 40
SPACING = 25
COLUMNS = 3
PILL_GAP = 24
PILL_MIN_HEIGHT = 150
BUBBLE_SIZE = 86
BUBBLE_RED = rl.Color(226, 60, 52, 255)
BUBBLE_RED_PRESSED = rl.Color(245, 92, 82, 255)
BUBBLE_GREY = rl.Color(70, 72, 78, 255)
BUBBLE_GREY_PRESSED = rl.Color(95, 98, 106, 255)
BUBBLE_TEAL = rl.Color(16, 185, 169, 255)
BUBBLE_TEAL_PRESSED = rl.Color(22, 210, 192, 255)
SETTINGS_TRANSITION_SECONDS = 0.28
TRANSITION_SURFACE_OVERSCAN = 4
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
class MenuTransitionState(Enum):
IDLE = auto()
PUSHING = auto()
POPPING = auto()
@dataclass
class MenuTransition:
state: MenuTransitionState = MenuTransitionState.IDLE
t: float = 0.0
duration: float = SETTINGS_TRANSITION_SECONDS
from_screen: object | None = None
to_screen: object | None = None
@property
def active(self) -> bool:
return self.state != MenuTransitionState.IDLE
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def _ease_out_cubic(x: float) -> float:
x = _clamp(x)
return 1.0 - pow(1.0 - x, 3.0)
def _ease_emphasized(x: float) -> float:
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
x = _clamp(x)
if x < 0.5:
return 4.0 * x * x * x
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
# Depth cues for the grid<->panel push/pop (see main.py for the same treatment).
TRANSITION_PARALLAX = 0.28
TRANSITION_MAX_DIM = 0.5
TRANSITION_SHADOW_W = 32
class SettingsPill(Widget):
"""A settings-grid button: icon + label on a rounded card, opens a sub-panel."""
BG = rl.Color(38, 40, 46, 255)
BG_PRESSED = rl.Color(54, 57, 65, 255)
BORDER = rl.Color(255, 255, 255, 38)
def __init__(self, icon_path: str, label: str | Callable[[], str], on_click: Callable[[], None]):
super().__init__()
self._label = label
self._icon = gui_app.texture(icon_path, 80, 80, keep_aspect_ratio=True) if icon_path else None
self.set_click_callback(on_click)
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.25, 20, self.BG_PRESSED if self.is_pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.25, 20, 2, self.BORDER)
font = gui_app.font(FontWeight.MEDIUM)
label_size = 50
label = self._label() if callable(self._label) else self._label
ts = measure_text_cached(font, label, label_size)
icon_w = self._icon.width if self._icon else 0
gap = 24 if self._icon else 0
group_w = icon_w + gap + ts.x
x = rect.x + (rect.width - group_w) / 2
cy = rect.y + rect.height / 2
if self._icon:
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
x += icon_w + gap
rl.draw_text_ex(font, label, rl.Vector2(int(x), int(cy - ts.y / 2)), label_size, 0, rl.WHITE)
class SettingsHubLayout(Widget):
"""Offroad Settings: a grid of pill buttons (the mockup look) that open the existing
IQ settings sub-panels. Back from a panel returns to the grid; back from the grid goes home.
"""
def __init__(self):
super().__init__()
self._params = Params()
self._settings = IQSettingsLayout() # one instance: reuse its configured panels + wiring
self._header = self._child(ScreenHeader(lambda: tr("Settings")))
self._on_close: Callable[[], None] | None = None
self._mode = "grid" # "grid" | "panel"
self._cur_panel = None
# Edge-swipe-back drag state (panel -> grid only)
self._swipe_start_pos: MousePos | None = None
self._swipe_active = False # past EDGE_SWIPE_ARM_DISTANCE, now tracking finger 1:1
self._swipe_blocked = False # vertical movement won out before we armed; ignore rest of this touch
self._swipe_dx = 0.0
# Release-settle animation (glide to grid/panel instead of snapping)
self._swipe_settling = False
self._swipe_settle_from = 0.0
self._swipe_settle_to = 0.0
self._swipe_settle_t = 0.0
self._swipe_completing = False
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
self._version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
text_color=rl.Color(185, 185, 190, 255),
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
wrap_text=False, scroll=True)
self._transition = MenuTransition()
# Restart / power-off / always-offroad / night-mode bubbles in the grid header
self._restart_icon = gui_app.texture("icons/iq/restart.png", 48, 48, keep_aspect_ratio=True)
self._power_icon = gui_app.texture("icons/iq/power.png", 48, 48, keep_aspect_ratio=True)
self._offroad_icon = gui_app.texture("icons/iq/square-parking.png", 48, 48, keep_aspect_ratio=True)
self._night_icon = gui_app.texture("icons/iq/moon.png", 48, 48, keep_aspect_ratio=True)
self._bell_icon = gui_app.texture("icons/iq/bell.png", 48, 48, keep_aspect_ratio=True)
self._bell_slash_icon = gui_app.texture("icons/iq/bell-slash.png", 48, 48, keep_aspect_ratio=True)
self._restart_rect = rl.Rectangle(0, 0, 0, 0)
self._power_rect = rl.Rectangle(0, 0, 0, 0)
self._offroad_rect = rl.Rectangle(0, 0, 0, 0)
self._night_rect = rl.Rectangle(0, 0, 0, 0)
self._silent_rect = rl.Rectangle(0, 0, 0, 0)
# Build the grid from the settings panels, skipping ones hidden from navigation (e.g. Cruise).
panels = self._settings._panels
hidden = self._settings._hidden_from_sidebar
self._grid_panels = [pt for pt in panels if pt not in hidden]
self._pills = [
SettingsPill(panels[pt].icon, lambda p=pt: tr(panels[p].name), (lambda p=pt: self._open_panel(p)))
for pt in self._grid_panels
]
# Route the Toggles -> Cruise shortcut into this hub's panel view.
cruise_pt = next((pt for pt in panels if pt.name == "CRUISE"), None)
toggles_pt = next((pt for pt in panels if pt.name == "TOGGLES"), None)
if cruise_pt is not None and toggles_pt is not None:
toggles = panels[toggles_pt].instance
if hasattr(toggles, "set_cruise_panel_callback"):
toggles.set_cruise_panel_callback(lambda: self._open_panel(cruise_pt))
def set_callbacks(self, on_close: Callable[[], None] | None = None):
self._on_close = on_close
def show_grid(self):
self._mode = "grid"
self._transition = MenuTransition()
def set_current_panel(self, panel_type):
self._open_panel(panel_type)
def show_event(self):
super().show_event()
self._mode = "grid"
self._transition = MenuTransition()
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
def _open_panel(self, panel_type):
if self._transition.active:
return
from_grid = self._mode == "grid"
self._settings.set_current_panel(panel_type)
self._cur_panel = panel_type
if from_grid and not ui_state.started:
self._start_transition("grid", "panel", MenuTransitionState.PUSHING)
else:
self._mode = "panel"
def _back_to_grid(self):
if self._mode == "panel" and not self._transition.active:
self._start_transition("panel", "grid", MenuTransitionState.POPPING)
def _reset_swipe(self):
self._swipe_start_pos = None
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
super()._handle_mouse_event(mouse_event)
if self._swipe_settling:
return # let the release animation finish before accepting a new gesture
if mouse_event.slot != 0 or self._mode != "panel" or self._transition.active:
self._reset_swipe()
return
if mouse_event.left_pressed:
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
self._swipe_start_pos = mouse_event.pos
self._swipe_active = False
self._swipe_blocked = False
self._swipe_dx = 0.0
else:
self._reset_swipe()
elif self._swipe_start_pos is not None:
if mouse_event.left_down:
dx = mouse_event.pos.x - self._swipe_start_pos.x
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
if not self._swipe_active and not self._swipe_blocked:
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
self._swipe_blocked = True
elif dx > EDGE_SWIPE_ARM_DISTANCE:
self._swipe_active = True
if self._swipe_active:
self._swipe_dx = max(0.0, dx)
elif mouse_event.left_released:
if self._swipe_active:
complete = self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
self._begin_swipe_settle(complete)
self._reset_swipe()
def _start_transition(self, from_mode: str, to_mode: str, state: MenuTransitionState):
self._transition = MenuTransition(
state=state,
t=0.0,
duration=SETTINGS_TRANSITION_SECONDS,
from_screen=from_mode,
to_screen=to_mode,
)
def _render(self, rect: rl.Rectangle):
if self._render_transition(rect):
return
if self._swipe_settling:
if self._update_swipe_settle():
self._render_swipe_drag(rect)
return
# settled this frame; fall through to render the (possibly switched) mode
elif self._swipe_active and self._mode == "panel":
self._render_swipe_drag(rect)
return
self._render_mode(self._mode, rect)
def _begin_swipe_settle(self, complete: bool):
# On release, glide from where the finger let go to fully-open (complete -> grid) or closed
# (cancel -> stay on panel) instead of snapping.
self._swipe_settle_from = self._swipe_dx
self._swipe_settle_to = self._rect.width if complete else 0.0
self._swipe_completing = complete
self._swipe_settle_t = 0.0
self._swipe_settling = True
def _update_swipe_settle(self) -> bool:
"""Advance the release animation. Returns True while still animating."""
self._swipe_settle_t += rl.get_frame_time()
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
if p < 1.0:
return True
self._swipe_settling = False
completing = self._swipe_completing
self._swipe_completing = False
self._swipe_dx = 0.0
if completing:
self._mode = "grid"
self._transition = MenuTransition()
return False
def _render_swipe_drag(self, rect: rl.Rectangle):
# Live, finger-following preview of the panel -> grid pop, mirroring _render_transition's
# POPPING geometry but driven 1:1 by touch position instead of eased elapsed time.
dx = min(self._swipe_dx, rect.width)
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
self._render_mode_surface_translated("grid", rect, dx - rect.width)
self._render_mode_surface_translated("panel", rect, dx)
def _layout_rects(self, rect: rl.Rectangle) -> tuple[rl.Rectangle, rl.Rectangle]:
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
content_y = header_rect.y + HEADER_HEIGHT + SPACING
content_rect = rl.Rectangle(rect.x + MARGIN, content_y, rect.width - 2 * MARGIN,
rect.y + rect.height - content_y - MARGIN)
return header_rect, content_rect
def _render_mode(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
if mode == "panel" and self._cur_panel is not None:
self._render_panel(rect, interactive)
else:
self._render_grid(rect, interactive)
def _render_mode_surface(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
bg_rect = rl.Rectangle(
rect.x - TRANSITION_SURFACE_OVERSCAN,
rect.y - TRANSITION_SURFACE_OVERSCAN,
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
)
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
self._render_mode(mode, rect, interactive)
def _render_mode_surface_translated(self, mode: str, rect: rl.Rectangle, x_offset: float):
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
self._render_mode_surface(mode, translated_rect, interactive=False)
def _render_transition(self, rect: rl.Rectangle) -> bool:
if not self._transition.active:
return False
from_mode = self._transition.from_screen
to_mode = self._transition.to_screen
if from_mode is None or to_mode is None:
self._finish_transition()
return False
from_mode = str(from_mode)
to_mode = str(to_mode)
self._transition.t += rl.get_frame_time()
if self._transition.t >= self._transition.duration:
self._finish_transition()
return False
p = _clamp(self._transition.t / self._transition.duration)
e = _ease_emphasized(p)
w = rect.width
pushing = self._transition.state == MenuTransitionState.PUSHING
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
if pushing:
top_mode, back_mode = to_mode, from_mode
top_x = _lerp(w, 0.0, e)
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
else:
top_mode, back_mode = from_mode, to_mode
top_x = _lerp(0.0, w, e)
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
back_rect = rl.Rectangle(round(rect.x + back_x), rect.y, rect.width, rect.height)
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
self._render_mode_surface_translated(back_mode, rect, back_x)
if back_dim > 0:
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
# soft shadow cast by the top card's leading edge onto the page behind
edge_x = round(rect.x + top_x)
if edge_x > rect.x:
sh = int(min(TRANSITION_SHADOW_W, edge_x - rect.x))
rl.draw_rectangle_gradient_h(edge_x - sh, int(rect.y), sh, int(rect.height),
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
self._render_mode_surface_translated(top_mode, rect, top_x)
return True
def _finish_transition(self):
if self._transition.to_screen is not None:
self._mode = str(self._transition.to_screen)
self._transition = MenuTransition()
def _render_widget(self, widget: Widget, rect: rl.Rectangle, interactive: bool):
if interactive:
widget.render(rect)
return
enabled = widget._enabled
widget.set_enabled(False)
try:
widget.render(rect)
finally:
widget.set_enabled(enabled)
def _render_panel(self, rect: rl.Rectangle, interactive: bool = True):
header_rect, content_rect = self._layout_rects(rect)
self._header.set_title(tr(self._settings._panels[self._cur_panel].name))
self._header.set_on_back(self._back_to_grid)
self._header.set_title_offset(0)
self._render_widget(self._header, header_rect, interactive)
panel = self._settings._panels[self._settings._current_panel].instance
enabled = panel._enabled
if not interactive:
panel.set_enabled(False)
try:
self._settings._draw_current_panel(content_rect)
finally:
if not interactive:
panel.set_enabled(enabled)
def _render_grid(self, rect: rl.Rectangle, interactive: bool = True):
header_rect, content_rect = self._layout_rects(rect)
# Grid landing
title = tr("Settings")
title_offset = 5 * BUBBLE_SIZE + 4 * 18 + 36
self._header.set_title(title)
self._header.set_on_back(self._on_close)
self._header.set_title_offset(title_offset) # make room for the bubbles
self._render_widget(self._header, header_rect, interactive)
# Restart / power-off / always-offroad bubbles, just right of the back button
cy = header_rect.y + HEADER_HEIGHT / 2
bx = header_rect.x + BACK_BTN_SIZE + 24
self._restart_rect = rl.Rectangle(bx, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._power_rect = rl.Rectangle(bx + BUBBLE_SIZE + 18, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._offroad_rect = rl.Rectangle(bx + 2 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._night_rect = rl.Rectangle(bx + 3 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
self._silent_rect = rl.Rectangle(bx + 4 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
mouse = rl.get_mouse_position()
for r, icon in ((self._restart_rect, self._restart_icon), (self._power_rect, self._power_icon)):
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, r)
rl.draw_circle(int(r.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, BUBBLE_RED_PRESSED if pressed else BUBBLE_RED)
rl.draw_texture(icon, int(r.x + (BUBBLE_SIZE - icon.width) / 2), int(cy - icon.height / 2), rl.WHITE)
# Always Offroad bubble (grey when off, teal when on)
offroad_active = ui_state.params.get_bool("IQAlwaysOffroad")
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._offroad_rect)
offroad_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if offroad_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
rl.draw_circle(int(self._offroad_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, offroad_color)
rl.draw_texture(self._offroad_icon,
int(self._offroad_rect.x + (BUBBLE_SIZE - self._offroad_icon.width) / 2),
int(cy - self._offroad_icon.height / 2), rl.WHITE)
# Night Mode bubble (grey when off, teal when on)
night_active = ui_state.params.get_bool("NightMode")
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._night_rect)
night_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if night_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
rl.draw_circle(int(self._night_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, night_color)
rl.draw_texture(self._night_icon,
int(self._night_rect.x + (BUBBLE_SIZE - self._night_icon.width) / 2),
int(cy - self._night_icon.height / 2), rl.WHITE)
# Silent Mode bubble (grey bell when off, red bell-with-slash when on)
silent_active = ui_state.params.get_bool("IQAlertSilence")
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._silent_rect)
silent_color = (BUBBLE_RED_PRESSED if pressed else BUBBLE_RED) if silent_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
silent_icon = self._bell_slash_icon if silent_active else self._bell_icon
rl.draw_circle(int(self._silent_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, silent_color)
rl.draw_texture(silent_icon,
int(self._silent_rect.x + (BUBBLE_SIZE - silent_icon.width) / 2),
int(cy - silent_icon.height / 2), rl.WHITE)
# Small version label, top-right of the header row. Its scroll viewport
# starts after the title, so long branch text does not clip at the bubbles.
font = gui_app.font(FontWeight.MEDIUM)
title_font = gui_app.font(FontWeight.BOLD)
ver_fs = 44
title_size = measure_text_cached(title_font, title, 64)
ver_size = measure_text_cached(font, self._version_text, ver_fs)
title_x = header_rect.x + BACK_BTN_SIZE + 36 + title_offset
version_left = title_x + title_size.x + 36
version_right = header_rect.x + header_rect.width
version_rect = rl.Rectangle(version_left, header_rect.y, max(0, version_right - version_left), HEADER_HEIGHT)
if version_rect.width > 0:
if ver_size.x <= version_rect.width:
rl.draw_text_ex(font, self._version_text,
rl.Vector2(int(header_rect.x + header_rect.width - ver_size.x),
int(header_rect.y + (HEADER_HEIGHT - ver_size.y) / 2)),
ver_fs, 0, rl.Color(185, 185, 190, 255))
else:
self._version_label.set_text(self._version_text)
self._version_label.render(version_rect)
rows = (len(self._pills) + COLUMNS - 1) // COLUMNS
pill_w = (content_rect.width - PILL_GAP * (COLUMNS - 1)) / COLUMNS
pill_h = max(PILL_MIN_HEIGHT, (content_rect.height - PILL_GAP * (rows - 1)) / rows)
for i, pill in enumerate(self._pills):
col = i % COLUMNS
row = i // COLUMNS
px = content_rect.x + col * (pill_w + PILL_GAP)
py = content_rect.y + row * (pill_h + PILL_GAP)
self._render_widget(pill, rl.Rectangle(px, py, pill_w, pill_h), interactive)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._mode != "grid" or self._transition.active:
return
if rl.check_collision_point_rec(mouse_pos, self._restart_rect):
self._reboot_prompt()
elif rl.check_collision_point_rec(mouse_pos, self._power_rect):
self._power_off_prompt()
elif rl.check_collision_point_rec(mouse_pos, self._offroad_rect):
self._toggle_offroad_prompt()
elif rl.check_collision_point_rec(mouse_pos, self._night_rect):
ui_state.params.put_bool("NightMode", not ui_state.params.get_bool("NightMode"))
elif rl.check_collision_point_rec(mouse_pos, self._silent_rect):
ui_state.params.put_bool("IQAlertSilence", not ui_state.params.get_bool("IQAlertSilence"))
def _reboot_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
return
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
def _perform_reboot(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoReboot", True)
def _power_off_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
return
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
def _perform_power_off(self, result: int):
if not ui_state.engaged and result == DialogResult.CONFIRM:
self._params.put_bool_nonblocking("DoShutdown", True)
def _toggle_offroad_prompt(self):
if ui_state.engaged:
gui_app.set_modal_overlay(alert_dialog(tr("Disengage before forcing offroad")))
return
active = ui_state.params.get_bool("IQAlwaysOffroad")
msg = tr("Leave forced-offroad mode now?") if active else tr("Switch the device into forced-offroad mode?")
def _confirm(result: int):
if result == DialogResult.CONFIRM and not ui_state.engaged:
ui_state.params.put_bool("IQAlwaysOffroad", not active)
gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Confirm")), callback=_confirm)

View File

@@ -0,0 +1,325 @@
import pyray as rl
import time
from dataclasses import dataclass
from collections.abc import Callable
from iqpilot.cereal import log
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.selfdrive.ui.lib.wifi_ssid import current_ssid
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from iqpilot.system.ui.lib.multilang import tr, tr_noop
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
SIDEBAR_WIDTH = 300
METRIC_HEIGHT = 120
METRIC_WIDTH = 252
METRIC_MARGIN = 24
CARD_GAP = 16
STATUS_DOT_CENTER_X = 36
STATUS_TEXT_X = 66
STATUS_TEXT_RIGHT_MARGIN = 20
STATUS_LABEL_SIZE = 26
STATUS_VALUE_SIZE = 36
STATUS_LINE_GAP = 2
NETWORK_RECT = rl.Rectangle(24, 18, 252, 76)
SETTINGS_BTN = rl.Rectangle(24, 112, 252, 112)
HOME_BTN = rl.Rectangle(60, 860, 180, 180)
ThermalStatus = log.DeviceState.ThermalStatus
NetworkType = log.DeviceState.NetworkType
# Color scheme
class Colors:
WHITE = rl.WHITE
WHITE_DIM = rl.Color(255, 255, 255, 85)
GRAY = rl.Color(84, 84, 84, 255)
# Keep these in parity with the offroad home status indicators.
GOOD = rl.Color(16, 185, 169, 255)
WARNING = rl.Color(245, 166, 35, 255)
DANGER = rl.Color(226, 72, 58, 255)
# UI elements
CARD_BG = rl.Color(28, 30, 36, 255)
CARD_BG_PRESSED = rl.Color(50, 53, 61, 255)
METRIC_BORDER = rl.Color(255, 255, 255, 22)
BOOKMARK_BG = CARD_BG
BOOKMARK_BG_PRESSED = CARD_BG_PRESSED
BUTTON_NORMAL = rl.WHITE
BUTTON_PRESSED = rl.Color(255, 255, 255, 166)
NETWORK_TYPES = {
NetworkType.none: tr_noop("--"),
NetworkType.wifi: tr_noop("Wi-Fi"),
NetworkType.ethernet: tr_noop("ETH"),
NetworkType.cell2G: tr_noop("2G"),
NetworkType.cell3G: tr_noop("3G"),
NetworkType.cell4G: tr_noop("LTE"),
NetworkType.cell5G: tr_noop("5G"),
}
@dataclass(slots=True)
class MetricData:
label: str
value: str
color: rl.Color
def update(self, label: str, value: str, color: rl.Color):
self.label = label
self.value = value
self.color = color
class Sidebar(Widget):
def __init__(self):
Widget.__init__(self)
self._net_type = NETWORK_TYPES.get(NetworkType.none)
self._net_strength = 0
self._temp_status = MetricData(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD)
self._panda_status = MetricData(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
self._connect_status = MetricData(tr_noop("KONN3KT"), tr_noop("OFFLINE"), Colors.WARNING)
self._recording_audio = False
self._home_img = gui_app.texture("images/button_home.png", HOME_BTN.width, HOME_BTN.height)
self._konn3kt_home_logo = gui_app.texture("icons_mici/settings/konn3kt_icon.png", 104, 104)
self._settings_img = gui_app.texture("icons/iq/tile_settings.png", 92, 92, keep_aspect_ratio=True)
self._mic_img = gui_app.texture("icons_mici/microphone.png", 26, 30, keep_aspect_ratio=True)
self._live_view_img = gui_app.texture("icons/live_view.png", 38, 32, keep_aspect_ratio=True)
self._live_streaming = False
_net_base = "icons_mici/settings/network/"
self._cell_strength_icons = [
gui_app.texture(f"{_net_base}cell_strength_none.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_low.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_medium.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_high.png", 56, 56, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}cell_strength_full.png", 56, 56, keep_aspect_ratio=True),
]
self._wifi_strength_icons = [
gui_app.texture(f"{_net_base}wifi_strength_none.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_low.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_medium.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
gui_app.texture(f"{_net_base}wifi_strength_full.png", 64, 64, keep_aspect_ratio=True),
]
self._mic_indicator_rect = rl.Rectangle(0, 0, 0, 0)
self._font_regular = gui_app.font(FontWeight.MEDIUM)
self._font_bold = gui_app.font(FontWeight.BOLD)
# Callbacks
self._on_settings_click: Callable | None = None
self._on_flag_click: Callable | None = None
self._open_settings_callback: Callable | None = None
def set_callbacks(self, on_settings: Callable | None = None, on_flag: Callable | None = None,
open_settings: Callable | None = None):
self._on_settings_click = on_settings
self._on_flag_click = on_flag
self._open_settings_callback = open_settings
def _render(self, rect: rl.Rectangle):
# Background
rl.draw_rectangle_rec(rect, rl.BLACK)
self._draw_buttons(rect)
self._draw_network_indicator(rect)
self._draw_metrics(rect)
def _update_state(self):
sm = ui_state.sm
device_state = sm['deviceState']
self._recording_audio = ui_state.recording_audio
# Konn3kt Live View is streaming to a viewer (hephaestusd sets this while a session is live)
self._live_streaming = ui_state.params.get_bool("IsLiveStreaming")
self._update_network_status(device_state)
self._update_temperature_status(device_state)
self._update_connection_status(device_state)
self._update_panda_status()
def _update_network_status(self, device_state):
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
strength = device_state.networkStrength
self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
def _update_temperature_status(self, device_state):
thermal_status = device_state.thermalStatus
if thermal_status == ThermalStatus.green:
self._temp_status.update(tr_noop("TEMP"), tr_noop("GOOD"), Colors.GOOD)
elif thermal_status == ThermalStatus.yellow:
self._temp_status.update(tr_noop("TEMP"), tr_noop("OK"), Colors.WARNING)
else:
self._temp_status.update(tr_noop("TEMP"), tr_noop("HIGH"), Colors.DANGER)
def _update_connection_status(self, device_state):
last_ping = device_state.lastAthenaPingTime
if last_ping == 0:
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("OFFLINE"), Colors.WARNING)
elif time.monotonic_ns() - last_ping < 80_000_000_000: # 80 seconds in nanoseconds
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("ONLINE"), Colors.GOOD)
else:
self._connect_status.update(tr_noop("KONN3KT"), tr_noop("ERROR"), Colors.DANGER)
def _update_panda_status(self):
if ui_state.panda_type == log.PandaState.PandaType.unknown:
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("NO PANDA"), Colors.DANGER)
else:
self._panda_status.update(tr_noop("VEHICLE"), tr_noop("ONLINE"), Colors.GOOD)
def _handle_mouse_release(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN):
if self._on_settings_click:
self._on_settings_click()
elif rl.check_collision_point_rec(mouse_pos, HOME_BTN) and ui_state.started:
if self._on_flag_click:
self._on_flag_click()
elif self._recording_audio and rl.check_collision_point_rec(mouse_pos, self._mic_indicator_rect):
if self._open_settings_callback:
self._open_settings_callback()
def _draw_buttons(self, rect: rl.Rectangle):
mouse_pos = rl.get_mouse_position()
mouse_down = self.is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
# Settings button
settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN)
rl.draw_rectangle_rounded(SETTINGS_BTN, 0.28, 18, Colors.CARD_BG_PRESSED if settings_down else Colors.CARD_BG)
rl.draw_rectangle_rounded_lines_ex(SETTINGS_BTN, 0.28, 18, 2, rl.Color(255, 255, 255, 28))
tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL
rl.draw_texture(self._settings_img,
int(SETTINGS_BTN.x + (SETTINGS_BTN.width - self._settings_img.width) / 2),
int(SETTINGS_BTN.y + (SETTINGS_BTN.height - self._settings_img.height) / 2),
tint)
# Home/Flag button
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
if ui_state.started:
center_x = HOME_BTN.x + HOME_BTN.width / 2
center_y = HOME_BTN.y + HOME_BTN.height / 2
radius = min(HOME_BTN.width, HOME_BTN.height) * 0.5
fill = Colors.BOOKMARK_BG_PRESSED if flag_pressed else Colors.BOOKMARK_BG
tint = Colors.BUTTON_PRESSED if flag_pressed else Colors.BUTTON_NORMAL
rl.draw_circle(int(center_x), int(center_y), radius, fill)
self._draw_bookmark_icon(center_x, center_y, tint, fill)
else:
center_x = HOME_BTN.x + HOME_BTN.width / 2
center_y = HOME_BTN.y + HOME_BTN.height / 2
radius = min(HOME_BTN.width, HOME_BTN.height) * 0.47
circle_color = Colors.BUTTON_PRESSED if mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) else Colors.BUTTON_NORMAL
rl.draw_circle(int(center_x), int(center_y), radius, circle_color)
logo_x = center_x - self._konn3kt_home_logo.width / 2
logo_y = center_y - self._konn3kt_home_logo.height / 2
rl.draw_texture(self._konn3kt_home_logo, int(logo_x), int(logo_y), rl.WHITE)
# Status indicators (right-anchored row): Live View (when streaming to konn3kt) sits to the LEFT
# of the Microphone (when recording audio). Each shows independently. Mic keeps its tap target.
slot_w, slot_h, gap = 70, 38, 12
slot_y = rect.y + 240
row_right = rect.x + rect.width - 36 # right edge of the rightmost slot
indicators = []
if self._live_streaming:
indicators.append(("live", self._live_view_img))
if self._recording_audio:
indicators.append(("mic", self._mic_img))
total_w = len(indicators) * slot_w + max(0, len(indicators) - 1) * gap
slot_x = row_right - total_w
for kind, img in indicators:
slot = rl.Rectangle(slot_x, slot_y, slot_w, slot_h)
icon_x = int(slot.x + (slot_w - img.width) / 2)
icon_y = int(slot.y + (slot_h - img.height) / 2)
if kind == "mic":
self._mic_indicator_rect = slot
mic_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, slot)
tint = rl.Color(255, 255, 255, 150) if mic_pressed else rl.WHITE
rl.draw_texture(img, icon_x, icon_y, tint)
else:
rl.draw_texture(img, icon_x, icon_y, rl.WHITE)
slot_x += slot_w + gap
def _draw_network_indicator(self, rect: rl.Rectangle):
on_wifi = self._net_type == NETWORK_TYPES[NetworkType.wifi]
net_text = current_ssid(on_wifi) or tr(self._net_type)
icon_list = self._wifi_strength_icons if self._net_type in (NETWORK_TYPES[NetworkType.wifi], NETWORK_TYPES[NetworkType.ethernet]) else self._cell_strength_icons
signal_icon = icon_list[min(self._net_strength, len(icon_list) - 1)]
text_size = measure_text_cached(self._font_regular, net_text, 44)
content_w = signal_icon.width + 14 + text_size.x
icon_x = NETWORK_RECT.x + (NETWORK_RECT.width - content_w) / 2
icon_y = NETWORK_RECT.y + (NETWORK_RECT.height - signal_icon.height) / 2
rl.draw_texture(signal_icon, int(icon_x), int(icon_y), Colors.WHITE)
text_x = icon_x + signal_icon.width + 14
text_y = NETWORK_RECT.y + (NETWORK_RECT.height - text_size.y) / 2
rl.draw_text_ex(self._font_regular, net_text, rl.Vector2(int(text_x), int(text_y)), 44, 0, rl.Color(215, 215, 215, 255))
def _draw_metrics(self, rect: rl.Rectangle):
metric_count = 3
metrics_height = metric_count * METRIC_HEIGHT + (metric_count - 1) * CARD_GAP
available_top = SETTINGS_BTN.y + SETTINGS_BTN.height
available_height = HOME_BTN.y - available_top
first_metric_y = available_top + max(0, (available_height - metrics_height) / 2)
metrics = [
(self._temp_status, first_metric_y),
(self._panda_status, first_metric_y + METRIC_HEIGHT + CARD_GAP),
(self._connect_status, first_metric_y + 2 * (METRIC_HEIGHT + CARD_GAP)),
]
for metric, y_offset in metrics:
self._draw_metric(rect, metric, rect.y + y_offset)
def _draw_metric(self, rect: rl.Rectangle, metric: MetricData, y: float):
metric_rect = rl.Rectangle(rect.x + METRIC_MARGIN, y, METRIC_WIDTH, METRIC_HEIGHT)
rl.draw_rectangle_rounded(metric_rect, 0.28, 16, Colors.CARD_BG)
rl.draw_rectangle_rounded_lines_ex(metric_rect, 0.28, 16, 2, Colors.METRIC_BORDER)
dot_r = 11
label = tr(metric.label)
value = tr(metric.value)
dot_x = metric_rect.x + STATUS_DOT_CENTER_X
text_x = metric_rect.x + STATUS_TEXT_X
text_col_w = metric_rect.width - STATUS_TEXT_X - STATUS_TEXT_RIGHT_MARGIN
label_size = STATUS_LABEL_SIZE
value_size = STATUS_VALUE_SIZE
label_text_size = measure_text_cached(self._font_regular, label, label_size)
value_text_size = measure_text_cached(self._font_bold, value, value_size)
while value_text_size.x > text_col_w and value_size > 30:
value_size -= 2
value_text_size = measure_text_cached(self._font_bold, value, value_size)
text_h = label_text_size.y + value_text_size.y + STATUS_LINE_GAP
label_y = metric_rect.y + (metric_rect.height - text_h) / 2
value_y = label_y + label_text_size.y + STATUS_LINE_GAP
dot_y = label_y + text_h / 2
rl.draw_circle(int(dot_x), int(dot_y), dot_r, metric.color)
rl.begin_scissor_mode(int(text_x), int(metric_rect.y), int(text_col_w), int(metric_rect.height))
rl.draw_text_ex(self._font_regular, label, rl.Vector2(int(text_x), int(label_y)), label_size, 0, rl.Color(185, 185, 190, 255))
rl.draw_text_ex(self._font_bold, value, rl.Vector2(int(text_x), int(value_y)), value_size, 0, Colors.WHITE)
rl.end_scissor_mode()
def _draw_bookmark_icon(self, center_x: float, center_y: float, tint: rl.Color, cutout_color: rl.Color):
icon_w = 78
icon_h = 96
icon_rect = rl.Rectangle(center_x - icon_w / 2, center_y - icon_h / 2, icon_w, icon_h)
rl.draw_rectangle_rounded(icon_rect, 0.18, 14, tint)
notch_top = icon_rect.y + icon_rect.height - 31
notch_left = icon_rect.x + 10
notch_right = icon_rect.x + icon_rect.width - 10
notch_bottom = icon_rect.y + icon_rect.height
rl.draw_triangle(
rl.Vector2(center_x, notch_top),
rl.Vector2(notch_left, notch_bottom),
rl.Vector2(notch_right, notch_bottom),
cutout_color,
)

View File

@@ -0,0 +1,32 @@
import pyray as rl
from collections.abc import Callable
from iqpilot.ui.layouts.settings.drive_history import TripsLayout
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.widgets import Widget
MARGIN = 40
SPACING = 25
class StatsLayout(Widget):
"""Offroad Stats screen: drive stats (miles / time / routes) with a back header."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Stats")))
self._trips = self._child(TripsLayout())
def set_on_back(self, cb: Callable[[], None]) -> None:
self._header.set_on_back(cb)
def _render(self, rect: rl.Rectangle):
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
content_y = header_rect.y + HEADER_HEIGHT + SPACING
content_rect = rl.Rectangle(
rect.x + MARGIN, content_y, rect.width - 2 * MARGIN, rect.y + rect.height - content_y - MARGIN
)
self._trips.render(content_rect)

File diff suppressed because it is too large Load Diff