IQ.Pilot Release Commit @ bec7652
This commit is contained in:
0
iqpilot/selfdrive/ui/layouts/__init__.py
Normal file
0
iqpilot/selfdrive/ui/layouts/__init__.py
Normal file
957
iqpilot/selfdrive/ui/layouts/home.py
Normal file
957
iqpilot/selfdrive/ui/layouts/home.py
Normal 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
|
||||
470
iqpilot/selfdrive/ui/layouts/main.py
Normal file
470
iqpilot/selfdrive/ui/layouts/main.py
Normal 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)
|
||||
562
iqpilot/selfdrive/ui/layouts/nav.py
Normal file
562
iqpilot/selfdrive/ui/layouts/nav.py
Normal 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
|
||||
113
iqpilot/selfdrive/ui/layouts/onboarding.py
Normal file
113
iqpilot/selfdrive/ui/layouts/onboarding.py
Normal 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
|
||||
194
iqpilot/selfdrive/ui/layouts/routes.py
Normal file
194
iqpilot/selfdrive/ui/layouts/routes.py
Normal 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
|
||||
9
iqpilot/selfdrive/ui/layouts/settings/common.py
Normal file
9
iqpilot/selfdrive/ui/layouts/settings/common.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
||||
"""
|
||||
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
|
||||
def restart_needed_callback(_=None):
|
||||
ui_state.params.put_bool("OnroadCycleRequested", True)
|
||||
155
iqpilot/selfdrive/ui/layouts/settings/developer.py
Normal file
155
iqpilot/selfdrive/ui/layouts/settings/developer.py
Normal 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)
|
||||
201
iqpilot/selfdrive/ui/layouts/settings/device.py
Normal file
201
iqpilot/selfdrive/ui/layouts/settings/device.py
Normal 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)
|
||||
170
iqpilot/selfdrive/ui/layouts/settings/settings.py
Normal file
170
iqpilot/selfdrive/ui/layouts/settings/settings.py
Normal 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()
|
||||
262
iqpilot/selfdrive/ui/layouts/settings/software.py
Normal file
262
iqpilot/selfdrive/ui/layouts/settings/software.py
Normal 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)
|
||||
343
iqpilot/selfdrive/ui/layouts/settings/toggles.py
Normal file
343
iqpilot/selfdrive/ui/layouts/settings/toggles.py
Normal 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()
|
||||
557
iqpilot/selfdrive/ui/layouts/settings_hub.py
Normal file
557
iqpilot/selfdrive/ui/layouts/settings_hub.py
Normal 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)
|
||||
325
iqpilot/selfdrive/ui/layouts/sidebar.py
Normal file
325
iqpilot/selfdrive/ui/layouts/sidebar.py
Normal 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,
|
||||
)
|
||||
32
iqpilot/selfdrive/ui/layouts/stats.py
Normal file
32
iqpilot/selfdrive/ui/layouts/stats.py
Normal 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)
|
||||
1017
iqpilot/selfdrive/ui/layouts/video_player.py
Normal file
1017
iqpilot/selfdrive/ui/layouts/video_player.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user