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