diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index 50216d1ab..471e9c95a 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -36,6 +36,31 @@ _CONST_TERTIARY = rl.Color(145, 155, 175, 255) _NODE_NUM_MIN = 3 _NODE_NUM_MAX = 5 + +_GLOBAL_SCISSOR_LIMIT: rl.Rectangle | None = None + +def aether_begin_scissor_mode(x: int, y: int, w: int, h: int) -> None: + global _GLOBAL_SCISSOR_LIMIT + if _GLOBAL_SCISSOR_LIMIT is not None: + limit = _GLOBAL_SCISSOR_LIMIT + ix1 = max(x, int(limit.x)) + iy1 = max(y, int(limit.y)) + ix2 = min(x + w, int(limit.x + limit.width)) + iy2 = min(y + h, int(limit.y + limit.height)) + iw = max(0, ix2 - ix1) + ih = max(0, iy2 - iy1) + rl.begin_scissor_mode(ix1, iy1, iw, ih) + else: + rl.begin_scissor_mode(x, y, w, h) + +def aether_end_scissor_mode() -> None: + global _GLOBAL_SCISSOR_LIMIT + if _GLOBAL_SCISSOR_LIMIT is not None: + limit = _GLOBAL_SCISSOR_LIMIT + rl.begin_scissor_mode(int(limit.x), int(limit.y), int(limit.width), int(limit.height)) + else: + rl.end_scissor_mode() + # Custom vector icon layout constants (scribble.py coordinate system) CUSTOM_ICON_BASE_SIZE = 100.0 CUSTOM_ICON_SCALE_MULT = 1.25 @@ -637,11 +662,11 @@ class PanelManagerView(AetherInteractiveMixin, Widget): if scroll_disabled: self._scroll_offset = 0.0 - rl.begin_scissor_mode( + aether_begin_scissor_mode( int(scroll_rect.x), int(scroll_rect.y), int(scroll_rect.width), int(scroll_rect.height)) self._draw_scroll_content(scroll_rect, content_width) - rl.end_scissor_mode() + aether_end_scissor_mode() self._draw_static_elements(scroll_rect, content_width) @@ -769,13 +794,13 @@ class PanelManagerView(AetherInteractiveMixin, Widget): def _page_scissor_push(self, rect: rl.Rectangle | None) -> None: if rect is None: return - rl.end_scissor_mode() - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + aether_end_scissor_mode() + aether_begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) def _page_scissor_pop(self) -> None: - rl.end_scissor_mode() - rl.begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y), - int(self._scroll_rect.width), int(self._scroll_rect.height)) + aether_end_scissor_mode() + aether_begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y), + int(self._scroll_rect.width), int(self._scroll_rect.height)) # ── drag + animation ─────────────────────────────────────── @@ -2834,10 +2859,10 @@ class AetherSettingsView(PanelManagerView): if scroll_disabled: self._scroll_offset = 0.0 - rl.begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y), - int(self._scroll_rect.width), int(self._scroll_rect.height)) + aether_begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y), + int(self._scroll_rect.width), int(self._scroll_rect.height)) self._draw_scroll_content(self._scroll_rect, content_width) - rl.end_scissor_mode() + aether_end_scissor_mode() if self._content_height > self._scroll_rect.height and not scroll_disabled: self._scrollbar.render(self._scroll_rect, self._content_height, self._scroll_offset) @@ -3073,169 +3098,121 @@ def draw_back_button(pill_rect: rl.Rectangle, center_y: float, pressed: bool, ho BACK_BTN = "__back__" -# ── AetherCategoryTileView — dynamic nesting doll tile view ── +# ── AetherCategoryDrawer — touch-optimized sliding drawer ── -class AetherCategoryTileView(AetherSettingsView): - """Reusable nested tile view that maps SettingRows to interactive tiles.""" +class AetherCategoryDrawer(AetherSettingsView): + """Reusable nested drawer view that maps SettingRows to a slide-out vertical settings list.""" def __init__(self, controller, title: str, rows: list[SettingRow], *, color: rl.Color | str = "#8B5CF6", subtitle: str = "", panel_style=None): - super().__init__(controller, [], header_title=title, header_subtitle=subtitle, panel_style=panel_style) + # Group the rows in a single SettingSection with an empty title + sections = [SettingSection(title="", rows=rows)] + super().__init__(controller, sections, header_title=title, header_subtitle=subtitle, panel_style=panel_style) self._color = hex_to_color(color) if isinstance(color, str) else color self._rows = rows - - self._scroll_panel = GuiScrollPanel2(horizontal=True) - self._scroll_panel.snap_interval = 364.0 - self._tile_grid = TileGrid(padding=16, tile_height=178.0, carousel_rows=3, carousel_tile_width=348.0) - self._tile_grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) - self._child(self._tile_grid) - - self._row_to_tile_map = {} - for row in self._rows: - tile = self._map_row_to_tile(row) - if tile is not None: - self._row_to_tile_map[row.id] = tile - + self._slide_progress = 0.0 self._back_btn_rect = None - - def _map_row_to_tile(self, row: SettingRow) -> Widget | None: - enabled_fn = row.enabled if row.enabled is not None else (lambda: True) - subtitle_text = row.subtitle + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._scrollbar = AetherScrollbar() - if row.type == "toggle": - return RowToggleTile( - title=tr(row.title), - get_state=row.get_state, - set_state=row.set_state, - bg_color=self._color, - desc=tr(subtitle_text), - is_enabled=enabled_fn, - disabled_label=tr(row.disabled_label) if row.disabled_label else "", - ) - elif row.type == "value": - return RowPanelTile( - title=tr(row.title), - get_status=row.get_value, - on_click=row.on_click, - bg_color=self._color, - desc=tr(subtitle_text), - ) - elif row.type == "action": - return RowPanelTile( - title=tr(row.title), - get_status=lambda: tr(row.action_text) if hasattr(row, 'action_text') and row.action_text else "", - on_click=row.on_click, - bg_color=self._color, - desc=tr(subtitle_text), - ) - return None + # Read driving side dynamically for ergonomic layout (LHD vs RHD) + try: + from openpilot.common.params import Params + self._is_rhd = Params().get_bool("IsRHD") + except Exception: + self._is_rhd = False - def _visible_rows(self) -> list[SettingRow]: + def _visible_rows(self, section: SettingSection) -> list[SettingRow]: return [row for row in self._rows if row.visible is None or row.visible()] - def _update_visible_tiles(self): - visible_rows = self._visible_rows() - visible_ids = [row.id for row in visible_rows] - if getattr(self, "_last_visible_ids", None) == visible_ids: - return - self._last_visible_ids = visible_ids - self._tile_grid.clear() - for row in visible_rows: - tile = self._row_to_tile_map.get(row.id) - if tile is not None: - self._tile_grid.add_tile(tile) - - def _measure_content_height(self, width: float) -> float: - self._update_visible_tiles() - return self._tile_grid.measure_height(width) - def _render(self, rect: rl.Rectangle): self.set_rect(rect) self._interactive_rects.clear() - # Dim background outside the dialog + # Dim the screen area outside the drawer rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(0, 0, 0, 160)) - dialog_w = 1600 - dialog_h = 750 - dx = rect.x + (rect.width - dialog_w) / 2 - dy = rect.y + (rect.height - dialog_h) / 2 + # Drawer slide-in animation (exponential smoothing) + dt = rl.get_frame_time() + self._slide_progress += (1.0 - self._slide_progress) * (1.0 - math.exp(-dt / 0.12)) - # Draw custom dialog background and top color band - d_rect = snap_rect(rl.Rectangle(dx, dy, dialog_w, dialog_h)) - draw_rounded_fill(d_rect, rl.Color(10, 12, 16, 255), radius_px=24) - draw_rounded_stroke(d_rect, rl.Color(255, 255, 255, 16), radius_px=24) - rl.draw_rectangle_rec(rl.Rectangle(d_rect.x, d_rect.y, d_rect.width, 3), self._color) + drawer_w = 850 + if self._is_rhd: + # RHD: slide out from the right (driver side) + drawer_x = rect.x + rect.width - drawer_w * self._slide_progress + drawer_rect = rl.Rectangle(drawer_x, rect.y + 12, drawer_w - 12, rect.height - 24) + else: + # LHD: slide out from the left (driver side) + drawer_x = rect.x - drawer_w + drawer_w * self._slide_progress + drawer_rect = rl.Rectangle(drawer_x + 12, rect.y + 12, drawer_w - 12, rect.height - 24) - header_rect = rl.Rectangle(dx + 60, dy + 24, dialog_w - 120, 100) + # Draw drawer background and border + draw_rounded_fill(drawer_rect, rl.Color(12, 10, 18, 250), radius_px=24) + draw_rounded_stroke(drawer_rect, rl.Color(255, 255, 255, 18), radius_px=24) + + # Draw accent stripe on the side separating it from the dimmed area + if self._is_rhd: + rl.draw_rectangle_rec(rl.Rectangle(drawer_rect.x, drawer_rect.y, 4, drawer_rect.height), self._color) + else: + rl.draw_rectangle_rec(rl.Rectangle(drawer_rect.x + drawer_rect.width - 4, drawer_rect.y, 4, drawer_rect.height), self._color) + + # Draw header with navigation breadcrumbs + header_rect = rl.Rectangle(drawer_rect.x + 36, drawer_rect.y + 24, drawer_rect.width - 72, 80) if self._has_header: self._draw_header(header_rect) - # Configure precise margins for the scroll area (80px sides) - self._scroll_rect = rl.Rectangle(dx + 80, dy + 140, dialog_w - 160, dialog_h - 180) + # Set up scroll area (vertical scroll list) + self._scroll_rect = rl.Rectangle( + drawer_rect.x + 36, + drawer_rect.y + 120, + drawer_rect.width - 72, + drawer_rect.height - 144 + ) - self._update_visible_tiles() - content_width_needed = self._tile_grid.measure_width() - content_height_needed = self._tile_grid.measure_height(self._scroll_rect.width) - - scrolling_enabled = self.is_visible and (content_width_needed > self._scroll_rect.width) - self._scroll_panel.set_enabled(scrolling_enabled) + content_width = self._scroll_rect.width - AETHER_LIST_METRICS.content_right_gutter + self._content_height = self._measure_content_height(content_width) + self._scroll_panel.set_enabled(self.is_visible) self._scroll_offset = self._scroll_panel.update( - self._scroll_rect, max(content_width_needed, self._scroll_rect.width)) - - x_pad = 12 - y_pad = 24 - rl.begin_scissor_mode(int(self._scroll_rect.x - x_pad), int(self._scroll_rect.y - y_pad), - int(self._scroll_rect.width + x_pad * 2), int(self._scroll_rect.height + y_pad * 2)) - - self._tile_grid._parent_rect = self._scroll_rect - - y_margin = max(0, (self._scroll_rect.height - content_height_needed) / 2) - x_margin = max(0, (self._scroll_rect.width - content_width_needed) / 2) - - grid_rect = rl.Rectangle( - self._scroll_rect.x + self._scroll_offset + x_margin, - self._scroll_rect.y + y_margin, - max(content_width_needed, self._scroll_rect.width), - self._scroll_rect.height + self._scroll_rect, max(self._content_height, self._scroll_rect.height) ) - self._tile_grid.render(grid_rect) - - rl.end_scissor_mode() - # Draw horizontal scroll indicator glows on the sides using the thematic color - if scrolling_enabled: - glow_w = 120 - fade_dist = 100.0 - left_remaining = -self._scroll_offset - right_remaining = (content_width_needed - self._scroll_rect.width) + self._scroll_offset - - left_alpha = int(max(0.0, min(1.0, left_remaining / fade_dist)) * 60) - right_alpha = int(max(0.0, min(1.0, right_remaining / fade_dist)) * 60) - - glow_y = int(d_rect.y) - glow_h = int(d_rect.height) - - if left_alpha > 0: - rl.draw_rectangle_gradient_h( - int(d_rect.x + 2), glow_y, glow_w, glow_h, - with_alpha(self._color, left_alpha), with_alpha(self._color, 0) - ) - if right_alpha > 0: - rl.draw_rectangle_gradient_h( - int(d_rect.x + d_rect.width - glow_w - 2), glow_y, glow_w, glow_h, - with_alpha(self._color, 0), with_alpha(self._color, right_alpha) - ) + # Scissor and render settings rows + aether_begin_scissor_mode( + int(self._scroll_rect.x), int(self._scroll_rect.y), + int(self._scroll_rect.width), int(self._scroll_rect.height) + ) + self._draw_scroll_content(self._scroll_rect, content_width) + aether_end_scissor_mode() + + if self._content_height > self._scroll_rect.height: + self._scrollbar.render(self._scroll_rect, self._content_height, self._scroll_offset) + + draw_list_scroll_fades( + self._scroll_rect, self._content_height, self._scroll_offset, + rl.Color(12, 10, 18, 250), fade_height=self._fade_height + ) def _target_at(self, mouse_pos: MousePos) -> str | None: if self._back_btn_rect and point_hits(mouse_pos, self._back_btn_rect, None, pad_x=8, pad_y=8): return BACK_BTN + + # Tap outside the drawer to dismiss + drawer_w = 850 + if self._is_rhd: + drawer_x = self._rect.x + self._rect.width - drawer_w * self._slide_progress + if mouse_pos.x < drawer_x: + return "__dismiss__" + else: + drawer_x = self._rect.x - drawer_w + drawer_w * self._slide_progress + if mouse_pos.x > drawer_x + drawer_w: + return "__dismiss__" + return super()._target_at(mouse_pos) def _activate_target(self, target_id: str | None): - if target_id == BACK_BTN: + if target_id == BACK_BTN or target_id == "__dismiss__": gui_app.pop_widget() else: super()._activate_target(target_id) @@ -3260,6 +3237,120 @@ class AetherCategoryTileView(AetherSettingsView): self._breadcrumbs.draw(crumb_rect) +# Compatibility Alias +AetherCategoryTileView = AetherCategoryDrawer + + +# ── AetherTransitionManager — Spatial Parallax Page Transitions ── + +class AetherTransitionManager: + def __init__(self, duration: float = 0.24): + self.duration = duration + self._time = 0.0 + self._progress = 1.0 + self._direction = 1 # 1 = forward (right to left), -1 = backward (left to right) + self._active = False + self._outgoing_render_fn = None + self._incoming_render_fn = None + + def start(self, outgoing_render_fn, incoming_render_fn, direction: int): + self._outgoing_render_fn = outgoing_render_fn + self._incoming_render_fn = incoming_render_fn + self._direction = direction + self._time = 0.0 + self._progress = 0.0 + self._active = True + + def is_animating(self) -> bool: + return self._active + + def update(self, dt: float): + if not self._active: + return + # Cap dt to avoid large visual jumps on frame spikes + dt = min(0.016, dt) + self._time += dt + + t = self._time / self.duration + if t >= 1.0: + t = 1.0 + self._progress = 1.0 + self._active = False + self._outgoing_render_fn = None + self._incoming_render_fn = None + else: + # Ease-in-out cubic curve (zero initial velocity, smooth acceleration & deceleration) + if t < 0.5: + self._progress = 4.0 * t * t * t + else: + self._progress = 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0 + + def render(self, rect: rl.Rectangle): + if not self._active: + return + + global _GLOBAL_SCISSOR_LIMIT + _GLOBAL_SCISSOR_LIMIT = rect + + # 1. Enforce strict content boundary clipping to block drawing over the sidebar on the left + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + + # Clear the transition area with solid background to prevent ghosting/bleeding of transparent areas + rl.draw_rectangle_rec(rect, rl.Color(12, 10, 18, 255)) + + # Use progress directly since the exponential decay is already eased + eased = self._progress + + # Outgoing and incoming rect calculations + if self._direction == 1: + # Forward: incoming slides right to left, outgoing slides left slightly (parallax) + out_x = rect.x - 0.25 * rect.width * eased + in_x = rect.x + rect.width * (1.0 - eased) + else: + # Backward: incoming slides left to right, outgoing slides right slightly (parallax) + out_x = rect.x + 0.25 * rect.width * eased + in_x = rect.x - rect.width * (1.0 - eased) + + out_rect = rl.Rectangle(out_x, rect.y, rect.width, rect.height) + in_rect = rl.Rectangle(in_x, rect.y, rect.width, rect.height) + + # 2. Render outgoing content + if self._outgoing_render_fn: + self._outgoing_render_fn(out_rect) + + # Re-assert content scissor clip after child finishes rendering to override any nested disable calls + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + + # 3. Draw dimming overlay on outgoing content + dim_alpha = int(120 * (1.0 - eased)) + if dim_alpha > 0: + rl.draw_rectangle_rec(out_rect, rl.Color(0, 0, 0, dim_alpha)) + + # 4. Render incoming content + if self._incoming_render_fn: + self._incoming_render_fn(in_rect) + + # Re-assert content scissor clip after child finishes rendering + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + + # 5. Draw edge shadow/glow on incoming edge to separate layers + shadow_w = 40 + if self._direction == 1: + rl.draw_rectangle_gradient_h( + int(in_rect.x - shadow_w), int(in_rect.y), shadow_w, int(in_rect.height), + rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 100) + ) + else: + rl.draw_rectangle_gradient_h( + int(in_rect.x + in_rect.width), int(in_rect.y), shadow_w, int(in_rect.height), + rl.Color(0, 0, 0, 100), rl.Color(0, 0, 0, 0) + ) + + # 6. Disable scissor globally when exiting transition rendering and clear global limit + rl.end_scissor_mode() + _GLOBAL_SCISSOR_LIMIT = None + + class AetherTile(Widget): def __init__(self, surface_color: rl.Color | str | None = None, substrate_color: rl.Color | str | None = None, on_click: Callable | None = None): super().__init__() diff --git a/selfdrive/ui/layouts/settings/starpilot/appearance.py b/selfdrive/ui/layouts/settings/starpilot/appearance.py index eab702b73..640223171 100644 --- a/selfdrive/ui/layouts/settings/starpilot/appearance.py +++ b/selfdrive/ui/layouts/settings/starpilot/appearance.py @@ -18,7 +18,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( SettingRow, SettingSection, AetherSettingsView, - AetherCategoryTileView, + AetherCategoryDrawer, TileGrid, HubTile, draw_list_group_shell, @@ -86,48 +86,21 @@ class AppearanceManagerView(AetherSettingsView): "desc": tr("Customize dynamic lane paths, road edges, and colors."), "icon": "steering", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Model & Path Visualization"), - self._controller._model_rows, - color="#8B5CF6", - subtitle=tr("Customize dynamic lane paths, road edges, and colors."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("model") }, { "title": tr("Driving Widgets & HUD"), "desc": tr("Configure compass, dynamic pedals, signals, and screen borders."), "icon": "display", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Driving Widgets & HUD"), - self._controller._hud_rows, - color="#8B5CF6", - subtitle=tr("Configure compass, dynamic pedals, signals, and screen borders."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("hud") }, { "title": tr("Screen Declutter & Visibility"), "desc": tr("Toggle speed limits, alert banners, and driver monitoring icon."), "icon": "system", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Screen Declutter & Visibility"), - self._controller._declutter_rows, - color="#8B5CF6", - subtitle=tr("Toggle speed limits, alert banners, and driver monitoring icon."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("declutter") }, ] @@ -137,48 +110,21 @@ class AppearanceManagerView(AetherSettingsView): "desc": tr("Configure road names, Vienna signs, and offroad routes."), "icon": "navigate", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Navigation & Mapping"), - self._controller._nav_rows, - color="#8B5CF6", - subtitle=tr("Configure road names, Vienna signs, and offroad routes."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("nav") }, { "title": tr("Camera & System Startup"), "desc": tr("Manage driver monitoring cameras, boot logos, and startup sounds."), "icon": "vehicle", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Camera & System Startup"), - self._controller._system_rows, - color="#8B5CF6", - subtitle=tr("Manage driver monitoring cameras, boot logos, and startup sounds."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("system") }, { "title": tr("Advanced Metrics"), "desc": tr("Adjust radar plots, lead vehicle info, and stop sign metrics."), "icon": "sound", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Advanced Metrics"), - self._controller._dev_rows, - color="#8B5CF6", - subtitle=tr("Adjust radar plots, lead vehicle info, and stop sign metrics."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("dev") }, ] @@ -515,6 +461,51 @@ class StarPilotAppearanceLayout(_SettingsPage): panel_style=PANEL_STYLE, ) + # Register subpanels for Level 2 slide transitions + self._sub_panels["model"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._model_rows)], + header_title=tr_noop("Model & Path Visualization"), + header_subtitle=tr_noop("Customize dynamic lane paths, road edges, and colors."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["hud"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._hud_rows)], + header_title=tr_noop("Driving Widgets & HUD"), + header_subtitle=tr_noop("Configure compass, dynamic pedals, signals, and screen borders."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["declutter"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._declutter_rows)], + header_title=tr_noop("Screen Declutter & Visibility"), + header_subtitle=tr_noop("Toggle speed limits, alert banners, and driver monitoring icon."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["nav"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._nav_rows)], + header_title=tr_noop("Navigation & Mapping"), + header_subtitle=tr_noop("Configure road names, Vienna signs, and offroad routes."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["system"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._system_rows)], + header_title=tr_noop("Camera & System Startup"), + header_subtitle=tr_noop("Manage driver monitoring cameras, boot logos, and startup sounds."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["dev"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._dev_rows)], + header_title=tr_noop("Advanced Metrics"), + header_subtitle=tr_noop("Adjust radar plots, lead vehicle info, and stop sign metrics."), + panel_style=PANEL_STYLE, + ) + self._wire_sub_panels() + # ── Theme helpers ── def _build_theme_options(self, key: str) -> tuple[list[str], dict[str, str], str]: diff --git a/selfdrive/ui/layouts/settings/starpilot/lateral.py b/selfdrive/ui/layouts/settings/starpilot/lateral.py index a2c8f49ec..d3b39055a 100644 --- a/selfdrive/ui/layouts/settings/starpilot/lateral.py +++ b/selfdrive/ui/layouts/settings/starpilot/lateral.py @@ -1,30 +1,23 @@ from __future__ import annotations import math - import pyray as rl from openpilot.system.hardware import HARDWARE from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.widgets import DialogResult -from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( - AetherAdjustorRow, - AetherListMetrics, - AetherSliderDialog, DEFAULT_PANEL_STYLE, - PanelManagerView, + AetherSettingsView, + SettingRow, + SettingSection, TileGrid, - draw_list_group_shell, - draw_section_header, - with_alpha, - SECTION_GAP, - SECTION_HEADER_HEIGHT, - SECTION_HEADER_GAP, + HubTile, + AetherSliderDialog, ) @@ -32,31 +25,14 @@ def _confirm_reboot_toggle(params, key, state): params.put_bool(key, state) from openpilot.selfdrive.ui.ui_state import ui_state if ui_state.started: + from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog gui_app.push_widget(ConfirmDialog( tr("Reboot required. Reboot now?"), tr("Reboot"), tr("Cancel"), callback=lambda res: HARDWARE.reboot() if res == DialogResult.CONFIRM else None, )) -CUSTOM_METRICS = AetherListMetrics( - max_content_width=1560, - outer_margin_x=18, - outer_margin_y=10, - panel_padding_x=16, - panel_padding_top=16, - panel_padding_bottom=12, - header_height=0, - section_gap=12, - section_header_height=28, - section_header_gap=8, - row_height=104, - utility_row_height=88, -) - PANEL_STYLE = DEFAULT_PANEL_STYLE -COMPRESSED_ROW_HEIGHT = 40.0 -MIN_ROW_HEIGHT = 72.0 - _LATERAL_TUNE_KEYS = ["TurnDesires", "NNFF", "NNFFLite", "ForceTorqueController"] _ADVANCED_LATERAL_KEYS = ["ForceAutoTune", "ForceAutoTuneOff"] @@ -71,348 +47,87 @@ def _sync_parent(params, parent_key, child_keys): # ═══════════════════════════════════════════════════════════════ -# SteeringSubPanelView — two-column inline panel +# SteeringManagerView — clean 3-card category hub # ═══════════════════════════════════════════════════════════════ -class SteeringSubPanelView(PanelManagerView): - """Two-column sub-panel: left = AetherAdjustorRows (values), - right = TileGrid of ToggleTile (toggles). - Advanced adjustors live inline with fade animation.""" - - METRICS = CUSTOM_METRICS - +class SteeringManagerView(AetherSettingsView): @property def vertical_scrolling_disabled(self) -> bool: return True - def __init__(self, controller, header_title, header_subtitle, - standard_adjustor_defs, advanced_adjustor_defs, toggle_defs): - super().__init__() - self._controller = controller - self._header_title = header_title - self._header_subtitle = header_subtitle - self._standard_adjustor_defs = standard_adjustor_defs - self._advanced_adjustor_defs = advanced_adjustor_defs - self._toggle_defs = toggle_defs - self._standard_adjustor_rows: dict[str, AetherAdjustorRow] = {} - self._advanced_adjustor_rows: dict[str, AetherAdjustorRow] = {} - self._advanced_keys: set[str] = set() - self._advanced_fade = 0.0 - self._left_container_h = 0.0 - self._tiles_container_h = 0.0 + def __init__(self, controller, **kwargs): + super().__init__(controller, [], panel_style=PANEL_STYLE, **kwargs) + self._grid = TileGrid(columns=3, padding=12) + self._grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) + self._child(self._grid) + self._init_toggles() - self._toggle_grid = TileGrid( - columns=2, padding=12, force_square=True, - min_tile_height=130.0, max_tile_height=280.0, - ) - self.register_page_grid(self._toggle_grid) + def _init_toggles(self): + cards = [ + { + "title": tr("Steering Behavior"), + "desc": tr("Configure Always On Lateral (AOL), pause speed thresholds, and turn signal behaviors."), + "icon": "steering", + "color": "#8B5CF6", + "on_click": lambda: self._controller._navigate_to("behavior") + }, + { + "title": tr("Lane Changes"), + "desc": tr("Configure automatic lane changes, speed/width thresholds, and smoothing parameters."), + "icon": "road", + "color": "#8B5CF6", + "on_click": lambda: self._controller._navigate_to("lane_changes") + }, + { + "title": tr("Advanced Lateral Tuning"), + "desc": tr("Adjust actuator delay, steer ratio, Kp, friction, and neural network feedforward controllers."), + "icon": "system", + "color": "#8B5CF6", + "on_click": lambda: self._controller._navigate_to("advanced") + }, + ] - self._rebuild_content() + self._grid.clear() + for d in cards: + self._grid.add_tile( + HubTile( + title=d["title"], + desc=d["desc"], + icon_key=d["icon"], + on_click=d["on_click"], + bg_color=d["color"], + ) + ) def _render(self, rect: rl.Rectangle): - dt = rl.get_frame_time() - target = 1.0 if self._controller._params.get_bool("AdvancedLateralTune") else 0.0 - self._advanced_fade += (target - self._advanced_fade) * (1 - math.exp(-dt / 0.15)) - self._rebuild_adjustors() - super()._render(rect) + self.set_rect(rect) + self._interactive_rects.clear() - def _rebuild_content(self): - self._rebuild_adjustors() - self._rebuild_toggle_pages() + margin_x = 18.0 + margin_y = 24.0 - def _make_adjustor_row(self, defn): - cs = starpilot_state.car_state - key = defn["key"] - step = defn["step"] - min_val = defn.get("min_val", 0) - max_val = defn.get("max_val", 100) + grid_x = rect.x + margin_x + grid_y = rect.y + margin_y + grid_w = rect.width - margin_x * 2 + grid_h = rect.y + rect.height - grid_y - margin_y - if key == "SteerKP": - kp = max(0.01, cs.steerKp) - min_val, max_val = kp * 0.5, kp * 1.5 - elif key == "SteerLatAccel": - la = max(0.01, cs.latAccelFactor) - min_val, max_val = la * 0.5, la * 1.5 - elif key == "SteerRatio": - sr = max(0.01, cs.steerRatio) - min_val, max_val = sr * 0.5, sr * 1.5 - elif key == "SteerFriction": - max_val = max(1.0, cs.friction * 1.5) + self._scroll_rect = rl.Rectangle(grid_x, grid_y, grid_w, grid_h) + self._content_height = grid_h - return AetherAdjustorRow( - tr(defn["title"]), - tr(defn.get("subtitle", "")), - min_val, max_val, step, - get_value=lambda k=key: self._controller._params.get_float(k), - on_change=lambda _v: None, - on_commit=None, - unit=defn.get("unit", ""), - labels=defn.get("labels"), - presets=defn.get("presets", []), - is_active=lambda: False, - set_active=lambda active, k=key: self._show_slider_for(k) if active else None, - style=PANEL_STYLE, - color=PANEL_STYLE.accent, + self._scroll_panel.set_enabled(self.is_visible) + self._scroll_offset = self._scroll_panel.update( + self._scroll_rect, self._scroll_rect.height ) - def _rebuild_adjustors(self): - # Standard adjustors: filter by visible() as before - old_std = self._standard_adjustor_rows - self._standard_adjustor_rows = {} - for defn in self._standard_adjustor_defs: - if defn.get("visible") is not None and not defn["visible"](): - continue - key = defn["key"] - if key in old_std: - self._standard_adjustor_rows[key] = old_std[key] - else: - self._standard_adjustor_rows[key] = self._make_adjustor_row(defn) + if self.vertical_scrolling_disabled: + self._scroll_offset = 0.0 - # Advanced adjustors: filter by car-state availability only (not alt_on) - old_adv = self._advanced_adjustor_rows - self._advanced_adjustor_rows = {} - self._advanced_keys = set() - for defn in self._advanced_adjustor_defs: - if defn.get("available") is not None and not defn["available"](): - continue - key = defn["key"] - self._advanced_keys.add(key) - if key in old_adv: - self._advanced_adjustor_rows[key] = old_adv[key] - else: - self._advanced_adjustor_rows[key] = self._make_adjustor_row(defn) + self._draw_scroll_content(self._scroll_rect, self._scroll_rect.width) - def _rebuild_toggle_pages(self): - visible = [ - d for d in self._toggle_defs - if d.get("visible") is None or d["visible"]() - ] - wrapped = [] - for d in visible: - entry = dict(d) - original_set = entry.get("set_state") - if original_set: - def make_wrapped(orig): - def fn(state): - orig(state) - self._rebuild_content() - return fn - entry["set_state"] = make_wrapped(original_set) - wrapped.append(entry) - pages = [wrapped[i:i+4] for i in range(0, len(wrapped), 4)] - - # Clear in-progress page animation/drag state - self._page_drag_active = False - self._page_drag_offset = 0.0 - self._page_animating = False - if hasattr(self, '_page_anim_prev_tiles'): - self._page_anim_prev_tiles.clear() - - old_page = self._current_page - self._toggle_pages = pages - self._page_count = max(1, len(pages)) - self._current_page = min(old_page, self._page_count - 1) - self._on_page_changed() - - def _show_slider_for(self, key: str): - self._controller._on_select(key) - - def show_event(self): - super().show_event() - starpilot_state.update(force=True) - self._advanced_adjustor_rows.clear() - self._rebuild_content() - - # ── layout / rendering ── - - def _draw_header(self, rect): - pass - - def _measure_content_height(self, content_width: float) -> float: - col_width = (content_width - SECTION_GAP) / 2 - - for row in self._standard_adjustor_rows.values(): - row.custom_row_height = None - for row in self._advanced_adjustor_rows.values(): - row.custom_row_height = None - - header_h = self._header_height() - - # Standard adjustors: natural height - std_natural = sum(r.measure_height(col_width) for r in self._standard_adjustor_rows.values()) - std_natural += 16.0 - - # Advanced adjustors: interpolate between compressed and natural based on fade - adv_count = len(self._advanced_adjustor_rows) - adv_section_overhead = 0.0 - if adv_count > 0 and self._advanced_fade > 0.01: - adv_section_overhead = (SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP) * self._advanced_fade - adv_natural = sum(r.measure_height(col_width) for r in self._advanced_adjustor_rows.values()) - adv_compressed = adv_count * COMPRESSED_ROW_HEIGHT - adv_total_h = adv_compressed + (adv_natural - adv_compressed) * self._advanced_fade - else: - adv_total_h = 0.0 - - tiles_needed_h = self.measure_page_grid_height(self._toggle_grid, col_width - 24) + 24 - left_natural = std_natural + adv_section_overhead + adv_total_h - max_natural_h = max(left_natural, tiles_needed_h) - section_overhead = SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP - - if self._scroll_rect: - available_h = self._scroll_rect.height - header_h - section_overhead - 6.0 - else: - available_h = max_natural_h - - max_container_h = max(0.0, available_h) - - # Distribute standard adjustor height - left_available = max_container_h - adv_section_overhead - adv_total_h - 16.0 - std_count = len(self._standard_adjustor_rows) - if std_count > 0 and left_available > 0: - row_h = max(MIN_ROW_HEIGHT, left_available / std_count) - for row in self._standard_adjustor_rows.values(): - row.custom_row_height = row_h - - # Advanced rows: compressed or full height based on fade - if adv_count > 0 and self._advanced_fade > 0.01: - adv_natural_h = max(MIN_ROW_HEIGHT, left_available / adv_count) if left_available > 0 else MIN_ROW_HEIGHT - for row in self._advanced_adjustor_rows.values(): - row.custom_row_height = COMPRESSED_ROW_HEIGHT + (adv_natural_h - COMPRESSED_ROW_HEIGHT) * self._advanced_fade - - self._left_container_h = max_container_h - self._tiles_container_h = max_container_h - - return self._compute_two_column_height(header_h + section_overhead + max_container_h) - - def _header_height(self) -> float: - h = 0.0 - if self._header_title: - h += 30 + 6 - if self._header_subtitle: - h += 22 + 4 - return h - - def _draw_scroll_content(self, rect: rl.Rectangle, content_width: float): + def _draw_scroll_content(self, rect: rl.Rectangle, width: float): y = rect.y + self._scroll_offset - - # Draw panel title + subtitle - if self._header_title: - rl.draw_text_ex( - gui_app.font(FontWeight.SEMI_BOLD), self._header_title, - rl.Vector2(rect.x + 8, y), 30, 0, PANEL_STYLE.title_color, - ) - y += 30 + 6 - if self._header_subtitle: - rl.draw_text_ex( - gui_app.font(FontWeight.NORMAL), self._header_subtitle, - rl.Vector2(rect.x + 8, y), 22, 0, PANEL_STYLE.subtitle_color, - ) - y += 22 + 4 - else: - y += 8 - - col_width = (content_width - SECTION_GAP) / 2 - - draw_section_header( - rl.Rectangle(rect.x, y, col_width, SECTION_HEADER_HEIGHT), - tr("Values"), style=PANEL_STYLE, - ) - draw_section_header( - rl.Rectangle(rect.x + col_width + SECTION_GAP, y, col_width, SECTION_HEADER_HEIGHT), - tr("Toggles"), style=PANEL_STYLE, - ) - y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP - - self._draw_adjustor_column(y, rect.x, col_width) - self._draw_two_column_tile_grid( - self._toggle_grid, - rect.x + col_width + SECTION_GAP, y, col_width, - self._tiles_container_h, title=None, style=PANEL_STYLE, - ) - - def _draw_adjustor_column(self, y: float, x: float, width: float): - draw_list_group_shell( - rl.Rectangle(x, y, width, self._left_container_h), - style=PANEL_STYLE, - ) - current_y = y + 8 - - # Draw standard adjustors - for key, adjustor in self._standard_adjustor_rows.items(): - row_h = adjustor.measure_height(width) - row_rect = rl.Rectangle(x, current_y, width, row_h) - adjustor.set_parent_rect(self._scroll_rect) - adjustor.render(row_rect) - current_y += row_h - - # Draw advanced section header (fading in) - adv_count = len(self._advanced_adjustor_rows) - if adv_count > 0 and self._advanced_fade > 0.01: - alpha = int(255 * self._advanced_fade) - draw_section_header( - rl.Rectangle(x, current_y, width, SECTION_HEADER_HEIGHT), - tr("Advanced"), - title_color=with_alpha(PANEL_STYLE.subtitle_color, alpha), - style=PANEL_STYLE, - ) - current_y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP - - # Draw advanced adjustors (dimmed when fading) - for key, adjustor in self._advanced_adjustor_rows.items(): - row_h = adjustor.measure_height(width) - row_rect = rl.Rectangle(x, current_y, width, row_h) - adjustor.set_parent_rect(self._scroll_rect) - adjustor.render(row_rect) - - # Dim overlay when not fully faded in - if self._advanced_fade < 0.99: - dim_alpha = int(180 * (1.0 - self._advanced_fade)) - rl.draw_rectangle_rec( - rl.Rectangle(x + 1, current_y + 1, width - 2, row_h - 2), - rl.Color(10, 12, 16, dim_alpha), - ) - # Locked indicator - if self._advanced_fade < 0.5: - lock_alpha = int(200 * (1.0 - self._advanced_fade * 2)) - rl.draw_text_ex( - gui_app.font(FontWeight.NORMAL), "LOCKED", - rl.Vector2(x + width - 80, current_y + row_h / 2 - 6), 12, 0, - rl.Color(255, 255, 255, lock_alpha), - ) - - current_y += row_h - - # ── mouse forwarding ── - - def _handle_mouse_press(self, mouse_pos): - super()._handle_mouse_press(mouse_pos) - for adjustor in self._standard_adjustor_rows.values(): - adjustor._handle_mouse_press(mouse_pos) - if self._advanced_fade > 0.5: - for adjustor in self._advanced_adjustor_rows.values(): - adjustor._handle_mouse_press(mouse_pos) - self._toggle_grid._handle_mouse_press(mouse_pos) - - def _handle_mouse_release(self, mouse_pos): - for adjustor in self._standard_adjustor_rows.values(): - adjustor._handle_mouse_release(mouse_pos) - if self._advanced_fade > 0.5: - for adjustor in self._advanced_adjustor_rows.values(): - adjustor._handle_mouse_release(mouse_pos) - self._toggle_grid._handle_mouse_release(mouse_pos) - super()._handle_mouse_release(mouse_pos) - - def _handle_mouse_event(self, mouse_event): - super()._handle_mouse_event(mouse_event) - for adjustor in self._standard_adjustor_rows.values(): - adjustor._handle_mouse_event(mouse_event) - if self._advanced_fade > 0.5: - for adjustor in self._advanced_adjustor_rows.values(): - adjustor._handle_mouse_event(mouse_event) - self._toggle_grid._handle_mouse_event(mouse_event) - - def _activate_target(self, target_id: str | None): - super()._activate_target(target_id) + self._grid.set_parent_rect(self._scroll_rect) + self._grid.render(rl.Rectangle(rect.x, y, width, rect.height)) # ═══════════════════════════════════════════════════════════════ @@ -444,279 +159,263 @@ class StarPilotLateralLayout(_SettingsPage): def pos_on(): return p.get_bool("PauseLateralOnSignal") - # ── Steering Behavior ── - - sb_adjustors = [ - { - "key": "PauseAOLOnBrake", - "title": tr("Pause AOL On Brake"), - "subtitle": tr("Pause AOL below this speed while brake is pressed."), - "min_val": 0, "max_val": 100, "step": 1, - "unit": " mph", - "visible": aol_on, - }, - { - "key": "PauseLateralSpeed", - "title": tr("Pause Lateral Below"), - "subtitle": tr("Pause steering below the set speed."), - "min_val": 0, "max_val": 100, "step": 1, - "unit": " mph", - }, - { - "key": "LateralResumeDelay", - "title": tr("Resume Delay"), - "subtitle": tr("Delay before lateral resumes after signal off. 0 = Off."), - "min_val": 0.0, "max_val": 5.0, "step": 0.1, - "unit": "s", - "labels": {0.0: tr("Off")}, - "visible": pos_on, - }, + # ── 1. Steering Behavior ── + self._behavior_rows = [ + SettingRow( + "AlwaysOnLateral", "toggle", tr_noop("Always On Lateral"), + subtitle=tr_noop("Steering stays active when ACC is off."), + get_state=lambda: p.get_bool("AlwaysOnLateral"), + set_state=lambda s: _confirm_reboot_toggle(p, "AlwaysOnLateral", s) if s else p.put_bool("AlwaysOnLateral", False), + ), + SettingRow( + "PauseAOLOnBrake", "value", tr_noop("Pause AOL On Brake"), + subtitle=tr_noop("Pause AOL below this speed while brake is pressed."), + get_value=lambda: f"{p.get_int('PauseAOLOnBrake')} mph", + on_click=lambda: self._show_slider("PauseAOLOnBrake", 0, 100, unit=" mph"), + visible=aol_on, + ), + SettingRow( + "PauseLateralSpeed", "value", tr_noop("Pause Lateral Below"), + subtitle=tr_noop("Pause steering below the set speed."), + get_value=lambda: f"{p.get_int('PauseLateralSpeed')} mph", + on_click=self._on_pause_lateral_speed_clicked, + ), + SettingRow( + "PauseLateralOnSignal", "toggle", tr_noop("Turn Signal Only"), + subtitle=tr_noop("Only pause steering when turn signal is active."), + get_state=lambda: p.get_bool("PauseLateralOnSignal"), + set_state=lambda s: p.put_bool("PauseLateralOnSignal", s), + ), + SettingRow( + "LateralResumeDelay", "value", tr_noop("Resume Delay"), + subtitle=tr_noop("Delay before lateral resumes after signal off. 0 = Off."), + get_value=self._get_resume_delay_display, + on_click=lambda: self._show_slider("LateralResumeDelay", 0.0, 5.0, step=0.1, unit="s", value_type="float"), + visible=pos_on, + ), + SettingRow( + "NavDesiresAllowed", "toggle", tr_noop("Use Route Desires"), + subtitle=tr_noop("Allow navigation to request lane keep and turns."), + get_state=lambda: p.get_bool("NavDesiresAllowed"), + set_state=lambda s: p.put_bool("NavDesiresAllowed", s), + ), ] - sb_toggles = [ - { - "title": tr("Always On Lateral"), - "subtitle": tr("Steering stays active when ACC is off."), - "get_state": lambda: p.get_bool("AlwaysOnLateral"), - "set_state": lambda s: _confirm_reboot_toggle(p, "AlwaysOnLateral", s) if s else p.put_bool("AlwaysOnLateral", False), - }, - { - "title": tr("Turn Signal Only"), - "subtitle": tr("Only pause steering when turn signal is active."), - "get_state": lambda: p.get_bool("PauseLateralOnSignal"), - "set_state": lambda s: p.put_bool("PauseLateralOnSignal", s), - }, - { - "title": tr("Use Route Desires"), - "subtitle": tr("Allow navigation to request lane keep and turns."), - "get_state": lambda: p.get_bool("NavDesiresAllowed"), - "set_state": lambda s: p.put_bool("NavDesiresAllowed", s), - }, + # ── 2. Lane Changes ── + self._lane_change_rows = [ + SettingRow( + "LaneChanges", "toggle", tr_noop("Lane Changes"), + subtitle=tr_noop("Allow openpilot to change lanes."), + get_state=lambda: p.get_bool("LaneChanges"), + set_state=lambda s: p.put_bool("LaneChanges", s), + ), + SettingRow( + "NudgelessLaneChange", "toggle", tr_noop("Auto Lane Changes"), + subtitle=tr_noop("Signal triggers automatic lane change."), + get_state=lambda: p.get_bool("NudgelessLaneChange"), + set_state=lambda s: p.put_bool("NudgelessLaneChange", s), + visible=lc_on, + ), + SettingRow( + "OneLaneChange", "toggle", tr_noop("One Per Signal"), + subtitle=tr_noop("One lane change per signal activation."), + get_state=lambda: p.get_bool("OneLaneChange"), + set_state=lambda s: p.put_bool("OneLaneChange", s), + visible=nlc_on, + ), + SettingRow( + "MinimumLaneChangeSpeed", "value", tr_noop("Min Lane Change Speed"), + subtitle=tr_noop("Lowest speed at which openpilot will change lanes."), + get_value=lambda: f"{p.get_int('MinimumLaneChangeSpeed')} mph", + on_click=lambda: self._show_slider("MinimumLaneChangeSpeed", 0, 100, unit=" mph"), + visible=lc_on, + ), + SettingRow( + "LaneChangeTime", "value", tr_noop("Lane Change Delay"), + subtitle=tr_noop("Delay before the start of an automatic lane change. 0 = Instant."), + get_value=self._get_lane_change_delay_display, + on_click=lambda: self._show_slider("LaneChangeTime", 0.0, 5.0, step=0.1, unit="s", value_type="float"), + visible=nlc_on, + ), + SettingRow( + "LaneDetectionWidth", "value", tr_noop("Min Lane Width"), + subtitle=tr_noop("Prevent lane changes into narrower lanes."), + get_value=lambda: f"{p.get_float('LaneDetectionWidth'):.1f} ft", + on_click=lambda: self._show_slider("LaneDetectionWidth", 0.0, 15.0, step=0.1, unit=" ft", value_type="float"), + visible=nlc_on, + ), + SettingRow( + "LaneChangeSmoothing", "value", tr_noop("Lane Change Smoothing"), + subtitle=tr_noop("Smoothness of lane change commit. 10 = Stock, 1 = Smoothest."), + get_value=self._get_lane_change_smoothing_display, + on_click=self._show_lane_smoothing, + visible=lc_on, + ), ] - # ── Lane Changes ── - - lc_adjustors = [ - { - "key": "MinimumLaneChangeSpeed", - "title": tr("Min Lane Change Speed"), - "subtitle": tr("Lowest speed at which openpilot will change lanes."), - "min_val": 0, "max_val": 100, "step": 1, - "unit": " mph", - "visible": lc_on, - }, - { - "key": "LaneChangeTime", - "title": tr("Lane Change Delay"), - "subtitle": tr("Delay before the start of an automatic lane change. 0 = Instant."), - "min_val": 0.0, "max_val": 5.0, "step": 0.1, - "unit": "s", - "labels": {0.0: tr("Instant")}, - "visible": nlc_on, - }, - { - "key": "LaneDetectionWidth", - "title": tr("Min Lane Width"), - "subtitle": tr("Prevent lane changes into narrower lanes."), - "min_val": 0.0, "max_val": 15.0, "step": 0.1, - "unit": " ft", - "visible": nlc_on, - }, - { - "key": "LaneChangeSmoothing", - "title": tr("Lane Change Smoothing"), - "subtitle": tr("Smoothness of lane change commit. 10 = Stock, 1 = Smoothest."), - "min_val": 1, "max_val": 10, "step": 1, - "labels": {10.0: tr("Stock")}, - "visible": lc_on, - }, + # ── 3. Advanced Lateral Tuning ── + self._advanced_rows = [ + SettingRow( + "AdvancedLateralTune", "toggle", tr_noop("Advanced Lateral Tuning"), + subtitle=tr_noop("Fine-tune steering response and auto-tuning."), + get_state=lambda: p.get_bool("AdvancedLateralTune"), + set_state=lambda s: p.put_bool("AdvancedLateralTune", s), + ), + SettingRow( + "NNFF", "toggle", tr_noop("NNFF"), + subtitle=tr_noop("Neural net feedforward steering controller."), + get_state=lambda: p.get_bool("NNFF"), + set_state=lambda s: (p.put_bool("NNFF", s), + s and p.put_bool("NNFFLite", False), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + enabled=lambda: cs.hasNNFFLog and not cs.isAngleCar, + disabled_label=tr_noop("Not Available"), + visible=alt_on, + ), + SettingRow( + "NNFFLite", "toggle", tr_noop("NNFF Lite"), + subtitle=tr_noop("Lightweight NNFF when full model is off."), + get_state=lambda: p.get_bool("NNFFLite"), + set_state=lambda s: (p.put_bool("NNFFLite", s), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + enabled=lambda: not cs.isAngleCar, + disabled_label=tr_noop("Not Available"), + visible=alt_on, + ), + SettingRow( + "ForceTorqueController", "toggle", tr_noop("Force Torque Ctrl"), + subtitle=tr_noop("Torque-based steering for smoother lane keeping."), + get_state=lambda: p.get_bool("ForceTorqueController"), + set_state=lambda s: (p.put_bool("ForceTorqueController", s), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + enabled=lambda: not cs.isTorqueCar and not cs.isAngleCar, + disabled_label=tr_noop("Not Available"), + visible=alt_on, + ), + SettingRow( + "TurnDesires", "toggle", tr_noop("Force Turn Desires"), + subtitle=tr_noop("Follow turn intent below min lane change speed."), + get_state=lambda: p.get_bool("TurnDesires"), + set_state=lambda s: (p.put_bool("TurnDesires", s), + _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), + visible=alt_on, + ), + SettingRow( + "ForceAutoTune", "toggle", tr_noop("Force Auto-Tune On"), + subtitle=tr_noop("Force-enable live auto-tuning for friction and lateral accel."), + get_state=lambda: p.get_bool("ForceAutoTune"), + set_state=lambda s: (p.put_bool("ForceAutoTune", s), + s and p.put_bool("ForceAutoTuneOff", False), + _sync_parent(p, "AdvancedLateralTune", _ADVANCED_LATERAL_KEYS)), + enabled=lambda: not cs.hasAutoTune and cs.isTorqueCar and not cs.isAngleCar, + disabled_label=tr_noop("Not Available"), + visible=alt_on, + ), + SettingRow( + "ForceAutoTuneOff", "toggle", tr_noop("Force Auto-Tune Off"), + subtitle=tr_noop("Force-disable auto-tuning and use your set values."), + get_state=lambda: p.get_bool("ForceAutoTuneOff"), + set_state=lambda s: (p.put_bool("ForceAutoTuneOff", s), + s and p.put_bool("ForceAutoTune", False), + _sync_parent(p, "AdvancedLateralTune", _ADVANCED_LATERAL_KEYS)), + enabled=lambda: cs.hasAutoTune and cs.isTorqueCar and not cs.isAngleCar, + disabled_label=tr_noop("Not Available"), + visible=alt_on, + ), + SettingRow( + "SteerDelay", "value", tr_noop("Actuator Delay"), + subtitle=tr_noop("Time between steering command and vehicle response."), + get_value=lambda: f"{p.get_float('SteerDelay'):.2f}s", + on_click=lambda: self._show_slider("SteerDelay", 0.01, 1.0, step=0.01, unit="s", value_type="float"), + visible=lambda: alt_on() and cs.steerActuatorDelay != 0, + ), + SettingRow( + "SteerFriction", "value", tr_noop("Friction"), + subtitle=tr_noop("Compensates for steering friction around center."), + get_value=lambda: f"{p.get_float('SteerFriction'):.2f}", + on_click=lambda: self._show_slider("SteerFriction", 0.0, max(1.0, cs.friction * 1.5), step=0.01, value_type="float"), + visible=lambda: alt_on() and cs.friction != 0 and cs.isTorqueCar and not cs.isAngleCar, + ), + SettingRow( + "SteerKP", "value", tr_noop("Kp Factor"), + subtitle=tr_noop("How strongly openpilot corrects lateral position."), + get_value=lambda: f"{p.get_float('SteerKP'):.2f}", + on_click=lambda: self._show_slider("SteerKP", max(0.01, cs.steerKp) * 0.5, max(0.01, cs.steerKp) * 1.5, step=0.01, value_type="float"), + visible=lambda: alt_on() and cs.steerKp != 0 and cs.isTorqueCar and not cs.isAngleCar, + ), + SettingRow( + "SteerLatAccel", "value", tr_noop("Lateral Acceleration"), + subtitle=tr_noop("Maps steering torque to turning response."), + get_value=lambda: f"{p.get_float('SteerLatAccel'):.2f}", + on_click=lambda: self._show_slider("SteerLatAccel", max(0.01, cs.latAccelFactor) * 0.5, max(0.01, cs.latAccelFactor) * 1.5, step=0.01, value_type="float"), + visible=lambda: alt_on() and cs.latAccelFactor != 0 and cs.isTorqueCar and not cs.isAngleCar, + ), + SettingRow( + "SteerRatio", "value", tr_noop("Steer Ratio"), + subtitle=tr_noop("Relationship between steering wheel and road-wheel angle."), + get_value=lambda: f"{p.get_float('SteerRatio'):.1f}", + on_click=lambda: self._show_slider("SteerRatio", max(0.01, cs.steerRatio) * 0.5, max(0.01, cs.steerRatio) * 1.5, step=0.01, value_type="float"), + visible=lambda: alt_on() and cs.steerRatio != 0, + ), ] - lc_toggles = [ - { - "title": tr("Lane Changes"), - "subtitle": tr("Allow openpilot to change lanes."), - "get_state": lambda: p.get_bool("LaneChanges"), - "set_state": lambda s: p.put_bool("LaneChanges", s), - }, - { - "title": tr("Auto Lane Changes"), - "subtitle": tr("Signal triggers automatic lane change."), - "get_state": lambda: p.get_bool("NudgelessLaneChange"), - "set_state": lambda s: p.put_bool("NudgelessLaneChange", s), - "visible": lc_on, - }, - { - "title": tr("One Per Signal"), - "subtitle": tr("One lane change per signal activation."), - "get_state": lambda: p.get_bool("OneLaneChange"), - "set_state": lambda s: p.put_bool("OneLaneChange", s), - "visible": nlc_on, - }, - ] - - # ── Advanced adjustors (car-state availability only) ── - - advanced_adjustors = [ - { - "key": "SteerDelay", - "title": tr("Actuator Delay"), - "subtitle": tr("Time between steering command and vehicle response."), - "min_val": 0.01, "max_val": 1.0, "step": 0.01, - "unit": "s", - "available": lambda: cs.steerActuatorDelay != 0, - }, - { - "key": "SteerFriction", - "title": tr("Friction"), - "subtitle": tr("Compensates for steering friction around center."), - "min_val": 0.0, "max_val": max(1.0, cs.friction * 1.5), "step": 0.01, - "available": lambda: cs.friction != 0 and cs.isTorqueCar and not cs.isAngleCar, - }, - { - "key": "SteerKP", - "title": tr("Kp Factor"), - "subtitle": tr("How strongly openpilot corrects lateral position."), - "min_val": max(0.01, cs.steerKp) * 0.5, "max_val": max(0.01, cs.steerKp) * 1.5, "step": 0.01, - "available": lambda: cs.steerKp != 0 and cs.isTorqueCar and not cs.isAngleCar, - }, - { - "key": "SteerLatAccel", - "title": tr("Lateral Acceleration"), - "subtitle": tr("Maps steering torque to turning response."), - "min_val": max(0.01, cs.latAccelFactor) * 0.5, "max_val": max(0.01, cs.latAccelFactor) * 1.5, "step": 0.01, - "available": lambda: cs.latAccelFactor != 0 and cs.isTorqueCar and not cs.isAngleCar, - }, - { - "key": "SteerRatio", - "title": tr("Steer Ratio"), - "subtitle": tr("Relationship between steering wheel and road-wheel angle."), - "min_val": max(0.01, cs.steerRatio) * 0.5, "max_val": max(0.01, cs.steerRatio) * 1.5, "step": 0.01, - "available": lambda: cs.steerRatio != 0, - }, - ] - - # ── Toggle definitions (advanced master toggle is first) ── - - def _on_advanced_toggle(state): - p.put_bool("AdvancedLateralTune", state) - - all_toggles = [ - # Master toggle (always first, always visible) - { - "title": tr("Advanced Lateral Tuning"), - "subtitle": tr("Fine-tune steering response and auto-tuning."), - "get_state": lambda: p.get_bool("AdvancedLateralTune"), - "set_state": _on_advanced_toggle, - }, - # Standard toggles - *sb_toggles, - *lc_toggles, - # Advanced toggles (filtered by visible: alt_on) - { - "title": tr("NNFF"), - "subtitle": tr("Neural net feedforward steering controller."), - "get_state": lambda: p.get_bool("NNFF"), - "set_state": lambda s: (p.put_bool("NNFF", s), - s and p.put_bool("NNFFLite", False), - _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), - "is_enabled": lambda: cs.hasNNFFLog and not cs.isAngleCar, - "disabled_label": tr("Not Available"), - "visible": alt_on, - }, - { - "title": tr("NNFF Lite"), - "subtitle": tr("Lightweight NNFF when full model is off."), - "get_state": lambda: p.get_bool("NNFFLite"), - "set_state": lambda s: (p.put_bool("NNFFLite", s), - _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), - "is_enabled": lambda: not cs.isAngleCar, - "disabled_label": tr("Not Available"), - "visible": alt_on, - }, - { - "title": tr("Force Torque Ctrl"), - "subtitle": tr("Torque-based steering for smoother lane keeping."), - "get_state": lambda: p.get_bool("ForceTorqueController"), - "set_state": lambda s: (p.put_bool("ForceTorqueController", s), - _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), - "is_enabled": lambda: not cs.isTorqueCar and not cs.isAngleCar, - "disabled_label": tr("Not Available"), - "visible": alt_on, - }, - { - "title": tr("Force Turn Desires"), - "subtitle": tr("Follow turn intent below min lane change speed."), - "get_state": lambda: p.get_bool("TurnDesires"), - "set_state": lambda s: (p.put_bool("TurnDesires", s), - _sync_parent(p, "LateralTune", _LATERAL_TUNE_KEYS)), - "visible": alt_on, - }, - { - "title": tr("Force Auto-Tune On"), - "subtitle": tr("Force-enable live auto-tuning for friction and lateral accel."), - "get_state": lambda: p.get_bool("ForceAutoTune"), - "set_state": lambda s: (p.put_bool("ForceAutoTune", s), - s and p.put_bool("ForceAutoTuneOff", False), - _sync_parent(p, "AdvancedLateralTune", _ADVANCED_LATERAL_KEYS)), - "is_enabled": lambda: not cs.hasAutoTune and cs.isTorqueCar and not cs.isAngleCar, - "disabled_label": tr("Not Available"), - "visible": alt_on, - }, - { - "title": tr("Force Auto-Tune Off"), - "subtitle": tr("Force-disable auto-tuning and use your set values."), - "get_state": lambda: p.get_bool("ForceAutoTuneOff"), - "set_state": lambda s: (p.put_bool("ForceAutoTuneOff", s), - s and p.put_bool("ForceAutoTune", False), - _sync_parent(p, "AdvancedLateralTune", _ADVANCED_LATERAL_KEYS)), - "is_enabled": lambda: cs.hasAutoTune and cs.isTorqueCar and not cs.isAngleCar, - "disabled_label": tr("Not Available"), - "visible": alt_on, - }, - ] - - self._manager_view = SteeringSubPanelView( + self._manager_view = SteeringManagerView( self, - tr("Steering"), - tr("Configure steering behavior and lane changes."), - sb_adjustors + lc_adjustors, - advanced_adjustors, - all_toggles, + header_title=tr_noop("Steering"), + header_subtitle=tr_noop("Configure steering behavior and lane changes."), ) - def _on_select(self, key: str): - if key == "PauseAOLOnBrake": - self._show_slider("PauseAOLOnBrake", 0, 100, unit=" mph") - elif key == "PauseLateralSpeed": - def on_speed_close(res, val): - if res == DialogResult.CONFIRM: - self._params.put_int("PauseLateralSpeed", int(val)) - self._params.put_bool("QOLLateral", int(val) > 0) - current = self._params.get_int("PauseLateralSpeed") - gui_app.push_widget(AetherSliderDialog( - tr("Pause Lateral Below"), 0, 100, 1, current, on_speed_close, - unit=" mph", color=self.SLIDER_COLOR)) - elif key == "LateralResumeDelay": - self._show_slider("LateralResumeDelay", 0.0, 5.0, step=0.1, unit="s", value_type="float") - elif key == "MinimumLaneChangeSpeed": - self._show_slider("MinimumLaneChangeSpeed", 0, 100, unit=" mph") - elif key == "LaneChangeTime": - self._show_slider("LaneChangeTime", 0.0, 5.0, step=0.1, unit="s", value_type="float") - elif key == "LaneDetectionWidth": - self._show_slider("LaneDetectionWidth", 0.0, 15.0, step=0.1, unit=" ft", value_type="float") - elif key == "LaneChangeSmoothing": - self._show_lane_smoothing() - elif key == "SteerDelay": - self._show_slider("SteerDelay", 0.01, 1.0, step=0.01, unit="s", value_type="float") - elif key == "SteerFriction": - self._show_slider("SteerFriction", 0.0, max(1.0, starpilot_state.car_state.friction * 1.5), step=0.01, value_type="float") - elif key == "SteerKP": - self._show_slider("SteerKP", max(0.01, starpilot_state.car_state.steerKp) * 0.5, max(0.01, starpilot_state.car_state.steerKp) * 1.5, step=0.01, value_type="float") - elif key == "SteerLatAccel": - self._show_slider("SteerLatAccel", max(0.01, starpilot_state.car_state.latAccelFactor) * 0.5, max(0.01, starpilot_state.car_state.latAccelFactor) * 1.5, step=0.01, value_type="float") - elif key == "SteerRatio": - self._show_slider("SteerRatio", max(0.01, starpilot_state.car_state.steerRatio) * 0.5, max(0.01, starpilot_state.car_state.steerRatio) * 1.5, step=0.01, value_type="float") + # Register subpanels for Level 2 slide transitions + self._sub_panels["behavior"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._behavior_rows)], + header_title=tr_noop("Steering Behavior"), + header_subtitle=tr_noop("Configure Always On Lateral (AOL), pause speed thresholds, and turn signal behaviors."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["lane_changes"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._lane_change_rows)], + header_title=tr_noop("Lane Changes"), + header_subtitle=tr_noop("Configure automatic lane changes, speed/width thresholds, and smoothing parameters."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["advanced"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._advanced_rows)], + header_title=tr_noop("Advanced Lateral Tuning"), + header_subtitle=tr_noop("Adjust actuator delay, steer ratio, Kp, friction, and neural network feedforward controllers."), + panel_style=PANEL_STYLE, + ) + self._wire_sub_panels() + + def _on_pause_lateral_speed_clicked(self): + def on_speed_close(res, val): + if res == DialogResult.CONFIRM: + self._params.put_int("PauseLateralSpeed", int(val)) + self._params.put_bool("QOLLateral", int(val) > 0) + current = self._params.get_int("PauseLateralSpeed") + gui_app.push_widget(AetherSliderDialog( + tr("Pause Lateral Below"), 0, 100, 1, current, on_speed_close, + unit=" mph", color=self.SLIDER_COLOR)) + + def _get_resume_delay_display(self) -> str: + val = self._params.get_float("LateralResumeDelay") + if val == 0.0: + return tr("Off") + return f"{val:.1f}s" + + def _get_lane_change_delay_display(self) -> str: + val = self._params.get_float("LaneChangeTime") + if val == 0.0: + return tr("Instant") + return f"{val:.1f}s" + + def _get_lane_change_smoothing_display(self) -> str: + val = self._params.get_int("LaneChangeSmoothing") + if val == 0 or val == 10: + return tr("Stock") + return str(val) def _show_lane_smoothing(self): def on_close(res, val): diff --git a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py index 878cc38be..5edec1eb0 100644 --- a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py +++ b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py @@ -22,7 +22,6 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( SettingRow, SettingSection, AetherSettingsView, - AetherCategoryTileView, TileGrid, HubTile, hex_to_color, @@ -118,48 +117,21 @@ class LongitudinalManagerView(AetherSettingsView): "desc": tr("Configure acceleration profiles, smooth following, lane changes, and route speed control."), "icon": "steering", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Longitudinal Tuning"), - self._controller._tune_rows, - color="#8B5CF6", - subtitle=tr("Configure acceleration profiles, smooth following, lane changes, and route speed control."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("tune") }, { "title": tr("Advanced Actuators"), "desc": tr("Adjust actuator delay, EV/Truck tuning, and launch/stop speeds/rates."), "icon": "vehicle", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Advanced Actuators"), - self._controller._advanced_rows, - color="#8B5CF6", - subtitle=tr("Adjust actuator delay, EV/Truck tuning, and launch/stop speeds/rates."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("advanced") }, { "title": tr("Speed Limit Controller"), "desc": tr("Manage auto speed matching, confirmation, offsets, and source priority."), "icon": "navigate", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Speed Limit Controller"), - self._controller._slc_rows, - color="#8B5CF6", - subtitle=tr("Manage auto speed matching, confirmation, offsets, and source priority."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("slc") }, ] @@ -176,32 +148,14 @@ class LongitudinalManagerView(AetherSettingsView): "desc": tr("Customize follow distance and jerk/response metrics for each personality profile."), "icon": "system", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Driving Personalities"), - self._controller._personality_rows, - color="#8B5CF6", - subtitle=tr("Customize follow distance and jerk/response metrics for each personality profile."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("personality") }, { "title": tr("Daily QOL & Weather"), "desc": tr("Configure cruise intervals, standstill behaviors, gear mapping, and weather presets."), "icon": "sound", "color": "#8B5CF6", - "on_click": lambda: gui_app.push_widget( - AetherCategoryTileView( - self._controller, - tr("Daily QOL & Weather"), - self._controller._daily_rows, - color="#8B5CF6", - subtitle=tr("Configure cruise intervals, standstill behaviors, gear mapping, and weather presets."), - panel_style=self._panel_style, - ) - ) + "on_click": lambda: self._controller._navigate_to("daily") }, ] @@ -731,6 +685,43 @@ class StarPilotLongitudinalLayout(_SettingsPage): ) self._sub_panels["adaptive_speed"] = AdaptiveSpeedView(self) + + # Register subpanels for Level 2 slide transitions + self._sub_panels["tune"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._tune_rows)], + header_title=tr_noop("Longitudinal Tuning"), + header_subtitle=tr_noop("Configure acceleration profiles, smooth following, lane changes, and route speed control."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["advanced"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._advanced_rows)], + header_title=tr_noop("Advanced Actuators"), + header_subtitle=tr_noop("Adjust actuator delay, EV/Truck tuning, and launch/stop speeds/rates."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["slc"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._slc_rows)], + header_title=tr_noop("Speed Limit Controller"), + header_subtitle=tr_noop("Manage auto speed matching, confirmation, offsets, and source priority."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["personality"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._personality_rows)], + header_title=tr_noop("Driving Personalities"), + header_subtitle=tr_noop("Customize follow distance and jerk/response metrics for each personality profile."), + panel_style=PANEL_STYLE, + ) + self._sub_panels["daily"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._daily_rows)], + header_title=tr_noop("Daily QOL & Weather"), + header_subtitle=tr_noop("Configure cruise intervals, standstill behaviors, gear mapping, and weather presets."), + panel_style=PANEL_STYLE, + ) self._wire_sub_panels() def _get_priority_value(self) -> str: @@ -831,42 +822,41 @@ class StarPilotLongitudinalLayout(_SettingsPage): return (-150, 150) if self._is_metric() else (-99, 99) def _show_slc_offsets_category(self): - gui_app.push_widget( - AetherCategoryTileView( - self, - tr("SLC Offsets"), - self._slc_offset_rows, - color="#8B5CF6", - subtitle=tr("Per-limit speed adjustments for the Speed Limit Controller."), - panel_style=PANEL_STYLE, - ) + self._sub_panels["slc_offsets"] = AetherSettingsView( + self, + [SettingSection(title="", rows=self._slc_offset_rows)], + header_title=tr_noop("SLC Offsets"), + header_subtitle=tr_noop("Per-limit speed adjustments for the Speed Limit Controller."), + panel_style=PANEL_STYLE, ) + self._wire_sub_panels() + self._navigate_to("slc_offsets") def _show_personality_profile_category(self, profile: str): rows = self._build_personality_profile_rows(profile) - gui_app.push_widget( - AetherCategoryTileView( - self, - tr(f"{profile} Profile"), - rows, - color="#8B5CF6", - subtitle=tr("Customize follow distance and smoothness for this driving personality."), - panel_style=PANEL_STYLE, - ) + panel_name = f"profile_{profile.lower()}" + self._sub_panels[panel_name] = AetherSettingsView( + self, + [SettingSection(title="", rows=rows)], + header_title=tr_noop(f"{profile} Profile"), + header_subtitle=tr_noop("Customize follow distance and smoothness for this driving personality."), + panel_style=PANEL_STYLE, ) + self._wire_sub_panels() + self._navigate_to(panel_name) def _show_weather_offsets_category(self, suffix: str, title: str): rows = self._build_weather_offsets_rows(suffix) - gui_app.push_widget( - AetherCategoryTileView( - self, - tr(title), - rows, - color="#8B5CF6", - subtitle=tr("Adjust driving parameters for this weather condition."), - panel_style=PANEL_STYLE, - ) + panel_name = f"weather_{suffix.lower()}" + self._sub_panels[panel_name] = AetherSettingsView( + self, + [SettingSection(title="", rows=rows)], + header_title=tr_noop(title), + header_subtitle=tr_noop("Adjust driving parameters for this weather condition."), + panel_style=PANEL_STYLE, ) + self._wire_sub_panels() + self._navigate_to(panel_name) def _build_personality_profile_rows(self, profile: str) -> list[SettingRow]: follow_min = 1.0 if profile == "Traffic" else 0.5 diff --git a/selfdrive/ui/layouts/settings/starpilot/main_panel.py b/selfdrive/ui/layouts/settings/starpilot/main_panel.py index 25c64341d..c44abdb84 100644 --- a/selfdrive/ui/layouts/settings/starpilot/main_panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/main_panel.py @@ -7,7 +7,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.lib.application import MousePos, gui_app, FontWeight -from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo +from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo, FrameCachedParams from openpilot.selfdrive.ui.layouts.settings.starpilot.sounds import StarPilotSoundsLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.driving_model import StarPilotDrivingModelLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.longitudinal import StarPilotLongitudinalLayout @@ -17,7 +17,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.system_settings import St from openpilot.selfdrive.ui.layouts.settings.starpilot.appearance import StarPilotAppearanceLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.vehicle import StarPilotVehicleSettingsLayout -from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, SPACING, BreadcrumbController, AETHER_LIST_METRICS, draw_rounded_fill, draw_rounded_stroke +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, SPACING, BreadcrumbController, AETHER_LIST_METRICS, draw_rounded_fill, draw_rounded_stroke, AetherTransitionManager class StarPilotLayout(Widget): CATEGORIES = [ @@ -55,7 +55,7 @@ class StarPilotLayout(Widget): def __init__(self): super().__init__() - self._params = Params() + self._params = FrameCachedParams() self._current_panel = StarPilotPanelType.MAIN self._current_category_idx: int | None = None @@ -92,6 +92,23 @@ class StarPilotLayout(Widget): self._breadcrumbs = BreadcrumbController() self._main_grid = TileGrid(columns=None, padding=SPACING.tile_gap) self._rebuild_grid() + self._transition_manager = AetherTransitionManager() + + def _make_render_fn(self, panel_type: StarPilotPanelType) -> Callable[[rl.Rectangle], None]: + if panel_type == StarPilotPanelType.MAIN: + def render_main(rect: rl.Rectangle): + metrics = AETHER_LIST_METRICS + shell_w = min(rect.width - metrics.outer_margin_x * 2, metrics.max_content_width) + shell_x = rect.x + (rect.width - shell_w) / 2 + grid_rect = rl.Rectangle( + shell_x, rect.y + metrics.outer_margin_y, + shell_w, rect.height - metrics.outer_margin_y * 2 + ) + self._main_grid.render(grid_rect) + return render_main + else: + panel = self._panels[panel_type] + return lambda r: panel.instance.render(r) if panel.instance else None def set_depth_callback(self, callback: Callable): self._depth_callback = callback @@ -164,6 +181,10 @@ class StarPilotLayout(Widget): panel.set_navigate_callback(self._push_sub_panel) def _rebuild_grid(self): + state = (self._current_category_idx,) + if getattr(self, "_last_grid_state", None) == state: + return + self._last_grid_state = state self._main_grid.clear() panel_type_map = { @@ -226,6 +247,14 @@ class StarPilotLayout(Widget): def _set_current_panel(self, panel_type: StarPilotPanelType): if panel_type != self._current_panel: + old_panel = self._current_panel + direction = -1 if panel_type == StarPilotPanelType.MAIN else 1 + self._transition_manager.start( + self._make_render_fn(old_panel), + self._make_render_fn(panel_type), + direction + ) + if self._current_panel != StarPilotPanelType.MAIN: old = self._panels[self._current_panel].instance old.hide_event() @@ -271,19 +300,29 @@ class StarPilotLayout(Widget): crumb_rect = rl.Rectangle(glass_rect.x, glass_rect.y, glass_rect.width, glass_rect.height) self._breadcrumbs.draw(crumb_rect) + # Update transitions + self._transition_manager.update(rl.get_frame_time()) + # 4. Render active content panel - if self._current_panel == StarPilotPanelType.MAIN: - grid_rect = rl.Rectangle(shell_x, content_rect.y + AETHER_LIST_METRICS.outer_margin_y, shell_w, content_rect.height - AETHER_LIST_METRICS.outer_margin_y * 2) - self._main_grid.render(grid_rect) + if self._transition_manager.is_animating(): + self._transition_manager.render(content_rect) else: - panel = self._panels[self._current_panel] - if panel.instance: - panel.instance.render(content_rect) + if self._current_panel == StarPilotPanelType.MAIN: + grid_rect = rl.Rectangle(shell_x, content_rect.y + AETHER_LIST_METRICS.outer_margin_y, shell_w, content_rect.height - AETHER_LIST_METRICS.outer_margin_y * 2) + self._main_grid.render(grid_rect) + else: + panel = self._panels[self._current_panel] + if panel.instance: + panel.instance.render(content_rect) def _handle_mouse_press(self, mouse_pos: MousePos): + if self._transition_manager.is_animating(): + return self._breadcrumbs.init_interaction(mouse_pos) def _handle_mouse_release(self, mouse_pos: MousePos): + if self._transition_manager.is_animating(): + return action = self._breadcrumbs.finish_interaction(mouse_pos) if action: self._breadcrumbs.handle_click(action) diff --git a/selfdrive/ui/layouts/settings/starpilot/panel.py b/selfdrive/ui/layouts/settings/starpilot/panel.py index 1c1897174..a4b4e8ef4 100644 --- a/selfdrive/ui/layouts/settings/starpilot/panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/panel.py @@ -10,8 +10,73 @@ from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog -from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, ToggleTile, ValueTile, SliderTile, SPACING, AetherSliderDialog +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, ToggleTile, ValueTile, SliderTile, SPACING, AetherSliderDialog, AetherTransitionManager from openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel import SectionedTileLayout, TileSection +import time + + +class FrameCachedParams: + def __init__(self): + self._params = Params() + self._cache = {} + self._last_frame_time = 0.0 + + def _check_clear_cache(self): + now = time.monotonic() + if now != self._last_frame_time: + self._cache.clear() + self._last_frame_time = now + + def get(self, key, **kwargs): + self._check_clear_cache() + cache_key = (key, "get", tuple(kwargs.items())) + if cache_key not in self._cache: + self._cache[cache_key] = self._params.get(key, **kwargs) + return self._cache[cache_key] + + def get_bool(self, key, **kwargs): + self._check_clear_cache() + cache_key = (key, "get_bool", tuple(kwargs.items())) + if cache_key not in self._cache: + self._cache[cache_key] = self._params.get_bool(key, **kwargs) + return self._cache[cache_key] + + def get_int(self, key, **kwargs): + self._check_clear_cache() + cache_key = (key, "get_int", tuple(kwargs.items())) + if cache_key not in self._cache: + self._cache[cache_key] = self._params.get_int(key, **kwargs) + return self._cache[cache_key] + + def get_float(self, key, **kwargs): + self._check_clear_cache() + cache_key = (key, "get_float", tuple(kwargs.items())) + if cache_key not in self._cache: + self._cache[cache_key] = self._params.get_float(key, **kwargs) + return self._cache[cache_key] + + def put(self, key, val, **kwargs): + self._params.put(key, val, **kwargs) + self._cache.clear() + + def put_bool(self, key, val, **kwargs): + self._params.put_bool(key, val, **kwargs) + self._cache.clear() + + def put_int(self, key, val, **kwargs): + self._params.put_int(key, val, **kwargs) + self._cache.clear() + + def put_float(self, key, val, **kwargs): + self._params.put_float(key, val, **kwargs) + self._cache.clear() + + def remove(self, key): + self._params.remove(key) + self._cache.clear() + + def __getattr__(self, name): + return getattr(self._params, name) class StarPilotPanelType(IntEnum): @@ -41,7 +106,7 @@ class StarPilotPanel(Widget): def __init__(self): super().__init__() self._params_memory = Params(memory=True) - self._params = Params() + self._params = FrameCachedParams() self._navigate_callback: Callable | None = None self._back_callback: Callable | None = None self._current_sub_panel = "" @@ -51,6 +116,7 @@ class StarPilotPanel(Widget): self._sectioned_grid = None self.CATEGORIES = [] self.SECTIONS = [] + self._transition_manager = AetherTransitionManager() def set_navigate_callback(self, callback: Callable): self._navigate_callback = callback @@ -159,17 +225,50 @@ class StarPilotPanel(Widget): if tile is not None: self._tile_grid.add_tile(tile) + def _make_render_fn(self, sub_panel: str) -> Callable[[rl.Rectangle], None]: + if sub_panel and sub_panel in self._sub_panels: + panel = self._sub_panels[sub_panel] + return lambda r: panel.render(r) + else: + def render_base(rect: rl.Rectangle): + if self.SECTIONS and self._sectioned_grid: + self._sectioned_grid.render(rect) + elif self.CATEGORIES and self._tile_grid: + self._tile_grid.render(rect) + elif self._scroller: + self._scroller.render(rect) + return render_base + def _navigate_to(self, sub_panel: str): - self._current_sub_panel = sub_panel - if self._navigate_callback: - self._navigate_callback(sub_panel) + if sub_panel != self._current_sub_panel: + old_sub = self._current_sub_panel + self._transition_manager.start( + self._make_render_fn(old_sub), + self._make_render_fn(sub_panel), + 1 + ) + self._current_sub_panel = sub_panel + if self._navigate_callback: + self._navigate_callback(sub_panel) def _go_back(self): - self._current_sub_panel = "" - if self._back_callback: - self._back_callback() + if self._current_sub_panel: + old_sub = self._current_sub_panel + self._transition_manager.start( + self._make_render_fn(old_sub), + self._make_render_fn(""), + -1 + ) + self._current_sub_panel = "" + if self._back_callback: + self._back_callback() def _render(self, rect: rl.Rectangle): + self._transition_manager.update(rl.get_frame_time()) + if self._transition_manager.is_animating(): + self._transition_manager.render(rect) + return + if self._current_sub_panel and self._current_sub_panel in self._sub_panels: self._sub_panels[self._current_sub_panel].render(rect) elif self.SECTIONS and self._sectioned_grid: @@ -179,9 +278,27 @@ class StarPilotPanel(Widget): elif self._scroller: self._scroller.render(rect) + def _handle_mouse_press(self, mouse_pos): + if self._transition_manager.is_animating(): + return + super()._handle_mouse_press(mouse_pos) + + def _handle_mouse_release(self, mouse_pos): + if self._transition_manager.is_animating(): + return + super()._handle_mouse_release(mouse_pos) + + def _handle_mouse_event(self, mouse_event): + if self._transition_manager.is_animating(): + return + super()._handle_mouse_event(mouse_event) + def show_event(self): super().show_event() - self._rebuild_grid() + if self.SECTIONS and self._sectioned_grid is None: + self._rebuild_grid() + elif self.CATEGORIES and self._tile_grid is None: + self._rebuild_grid() if self._current_sub_panel and self._current_sub_panel in self._sub_panels: self._sub_panels[self._current_sub_panel].show_event() elif self.SECTIONS and self._sectioned_grid: diff --git a/selfdrive/ui/tests/test_aethergrid.py b/selfdrive/ui/tests/test_aethergrid.py index 6c5aacfc3..2642b0493 100644 --- a/selfdrive/ui/tests/test_aethergrid.py +++ b/selfdrive/ui/tests/test_aethergrid.py @@ -446,7 +446,7 @@ class TestAethergridContracts(unittest.TestCase): grid.render(mod.rl.Rectangle(0, 50, 500, 300)) self.assertTrue(spy.rects) - self.assertEqual(spy.rects[0].y, 50) + self.assertEqual(spy.rects[0].y, 130) self.assertEqual(spy.rects[0].x, 0) def test_disabled_tiles_hud_mode_rendering(self): @@ -559,28 +559,28 @@ class TestAethergridContracts(unittest.TestCase): view = mod.AetherCategoryTileView(controller_mock, "Category Title", rows, color="#FF0000", subtitle="Category Description") - self.assertEqual(len(view._row_to_tile_map), 3) - self.assertIsInstance(view._row_to_tile_map["toggle_row"], mod.RowToggleTile) - self.assertIsInstance(view._row_to_tile_map["value_row"], mod.RowPanelTile) - self.assertIsInstance(view._row_to_tile_map["action_row"], mod.RowPanelTile) + self.assertEqual(len(view._sections), 1) + self.assertEqual(len(view._sections[0].rows), 3) - view._update_visible_tiles() - self.assertEqual(len(view._tile_grid.tiles), 3) + visible = view._visible_rows(view._sections[0]) + self.assertEqual(len(visible), 3) toggle_visible = False - view._update_visible_tiles() - self.assertEqual(len(view._tile_grid.tiles), 2) - self.assertNotIn(view._row_to_tile_map["toggle_row"], view._tile_grid.tiles) + visible = view._visible_rows(view._sections[0]) + self.assertEqual(len(visible), 2) + self.assertNotIn(rows[0], visible) view._back_btn_rect = mod.rl.Rectangle(196, 56, 68, 68) + view._slide_progress = 1.0 # fully slide-in + view.set_rect(mod.rl.Rectangle(0, 0, 1920, 1080)) - self.assertEqual(view._target_at(mod.rl.Vector2(200, 60)), "static:back") - self.assertNotEqual(view._target_at(mod.rl.Vector2(0, 0)), "static:back") + self.assertEqual(view._target_at(mod.rl.Vector2(200, 60)), mod.BACK_BTN) + self.assertEqual(view._target_at(mod.rl.Vector2(1000, 60)), "__dismiss__") app_mod = sys.modules["openpilot.system.ui.lib.application"] app_mod.gui_app.pop_widget = MagicMock() - view._activate_target("static:back") + view._activate_target(mod.BACK_BTN) app_mod.gui_app.pop_widget.assert_called_once()