diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index 7182a1864..c594e9a04 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -26,7 +26,7 @@ MIN_TILE_WIDTH = 300 _HUD_BG_ON = rl.Color(12, 10, 18, 230) _HUD_BORDER_OFF = rl.Color(28, 27, 34, 255) -_HUD_TEXT_DIM = rl.Color(160, 160, 175, 255) +_HUD_TEXT_DIM = rl.Color(220, 220, 230, 220) _HUD_LED_BASE = rl.Color(36, 35, 44, 255) @@ -198,8 +198,8 @@ class AetherListColors: PANEL_BORDER = rl.Color(255, 255, 255, 22) PANEL_GLOW = rl.Color(92, 116, 151, 34) HEADER = rl.Color(236, 242, 250, 255) - SUBTEXT = rl.Color(164, 177, 196, 255) - MUTED = rl.Color(126, 139, 158, 255) + SUBTEXT = rl.Color(200, 210, 225, 255) + MUTED = rl.Color(160, 170, 185, 255) ROW_BG = rl.Color(255, 255, 255, 0) ROW_BORDER = rl.Color(255, 255, 255, 0) ROW_SEPARATOR = rl.Color(255, 255, 255, 16) @@ -1076,17 +1076,25 @@ def draw_action_pill( ) -def draw_chevron_icon(rect: rl.Rectangle, color: rl.Color, *, thickness: float = 3.0): +def draw_chevron_icon(rect: rl.Rectangle, color: rl.Color, *, thickness: float = 3.0, direction: str = "right"): snapped = _snap_rect(rect) center_x = snapped.x + snapped.width / 2 center_y = snapped.y + snapped.height / 2 size = max(6.0, min(snapped.width, snapped.height) * 0.28) - left_x = center_x - size * 0.6 - right_x = center_x + size * 0.35 - top_y = center_y - size - bottom_y = center_y + size - rl.draw_line_ex(rl.Vector2(left_x, top_y), rl.Vector2(right_x, center_y), thickness, color) - rl.draw_line_ex(rl.Vector2(left_x, bottom_y), rl.Vector2(right_x, center_y), thickness, color) + if direction == "left": + left_x = center_x - size * 0.35 + right_x = center_x + size * 0.6 + top_y = center_y - size + bottom_y = center_y + size + rl.draw_line_ex(rl.Vector2(right_x, top_y), rl.Vector2(left_x, center_y), thickness, color) + rl.draw_line_ex(rl.Vector2(right_x, bottom_y), rl.Vector2(left_x, center_y), thickness, color) + else: + left_x = center_x - size * 0.6 + right_x = center_x + size * 0.35 + top_y = center_y - size + bottom_y = center_y + size + rl.draw_line_ex(rl.Vector2(left_x, top_y), rl.Vector2(right_x, center_y), thickness, color) + rl.draw_line_ex(rl.Vector2(left_x, bottom_y), rl.Vector2(right_x, center_y), thickness, color) TAB_HEIGHT = 68 @@ -2578,6 +2586,181 @@ class AetherSettingsView(PanelManagerView): ) +# ── AetherCategoryTileView — dynamic nesting doll tile view ── + +class AetherCategoryTileView(AetherSettingsView): + """Reusable nested tile view that maps SettingRows to interactive tiles.""" + + 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) + 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._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 + + 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 + + def _visible_rows(self) -> 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 + 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 + + # 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) + + header_rect = rl.Rectangle(dx + 60, dy + 24, dialog_w - 120, 100) + 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) + + 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) + + 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._tile_grid.render(grid_rect) + + rl.end_scissor_mode() + + def _target_at(self, mouse_pos: MousePos) -> str | None: + if self._back_btn_rect and rl.check_collision_point_rec(mouse_pos, self._back_btn_rect): + return "static:back" + return super()._target_at(mouse_pos) + + def _activate_target(self, target_id: str | None): + if target_id == "static:back": + gui_app.pop_widget() + else: + super()._activate_target(target_id) + + def _draw_header(self, rect: rl.Rectangle): + btn_w = 68.0 + btn_h = 68.0 + self._back_btn_rect = rl.Rectangle(rect.x, rect.y + 4.0, btn_w, btn_h) + + self._interactive_rects["static:back"] = self._back_btn_rect + + hovered = rl.check_collision_point_rec(gui_app.last_mouse_event.pos, self._back_btn_rect) + pressed = self._pressed_target == "static:back" and hovered + + if pressed: + fill = rl.Color(255, 255, 255, 30) + border = self._panel_style.accent + elif hovered: + fill = rl.Color(255, 255, 255, 18) + border = self._panel_style.accent + else: + fill = rl.Color(255, 255, 255, 8) + border = rl.Color(255, 255, 255, 20) + + draw_soft_card(self._back_btn_rect, fill, border, radius=0.5) + draw_chevron_icon(self._back_btn_rect, self._panel_style.accent if (hovered or pressed) else AetherListColors.HEADER, direction="left") + + title_x = rect.x + btn_w + 24 + title_rect = rl.Rectangle(title_x, rect.y, rect.width - btn_w - 24, rect.height) + + title = tr(self._header_title) if self._header_title else "" + subtitle = tr(self._header_subtitle) if self._header_subtitle else "" + + draw_settings_panel_header(title_rect, title, subtitle) + + 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__() @@ -2661,6 +2844,56 @@ class AetherTile(Widget): return surface_rect + def _render_luxury_grid_layout( + self, + rect: rl.Rectangle, + title_text: str, + status_text: str, + is_active: bool, + status_color_override: rl.Color | None = None, + right_renderer: Callable[[float, float, float, float, float, rl.Color], None] | None = None + ): + enabled = self.enabled + self._animate_plate(rl.get_frame_time()) + + if not enabled: + self._plate_offset = 0.0 + self._plate_target = 0.0 + + color = getattr(self, "_active_color", getattr(self, "surface_color", rl.WHITE)) if enabled else getattr(self, "_disabled_color", rl.Color(120, 120, 120, 255)) + glow = getattr(self, "_glow", 1.0) if enabled else 0.0 + face, accent = self._render_hud_background(rect, color, glow) + + rx, ry, rw, rh = face.x, face.y, face.width, face.height + content_pad = max(24, int(rh * 0.15)) + + title_size = max(20, min(26, int(rh * 0.22))) + status_size = max(16, min(24, int(rh * 0.18))) + + title_color = rl.WHITE if (enabled and is_active) else rl.Color(220, 220, 230, 255) + + title_y = ry + (rh / 2) - title_size - 2 + status_y = ry + (rh / 2) + 12 + + max_text_width = rw - (content_pad * 2) - int(rh * 0.40) - 10 + font = getattr(self, "_font", gui_app.font(FontWeight.BOLD)) + font_desc = getattr(self, "_font_desc", gui_app.font(FontWeight.MEDIUM)) + + self._draw_text_fit(font, title_text, rl.Vector2(rx + content_pad, title_y), max_text_width, title_size, color=title_color) + + if not enabled and getattr(self, "_disabled_label", ""): + display_status = tr(self._disabled_label) if self._disabled_label else tr("LOCKED") + status_color = rl.Color(160, 160, 175, 255) + else: + display_status = status_text + status_color = status_color_override if status_color_override is not None else accent + + if display_status: + self._draw_text_fit(font_desc, display_status, rl.Vector2(rx + content_pad, status_y), max_text_width, status_size, color=status_color) + + if right_renderer: + right_renderer(rx, ry, rw, rh, content_pad, accent) + def _draw_text_fit( self, font: rl.Font, @@ -2975,9 +3208,9 @@ class HubTile(AetherTile): text_scale = max(0.82, min(1.12, min(rw / 360.0, rh / 205.0))) gap = SPACING.line_gap - title_size = max(18, int(round(22 * text_scale))) + title_size = max(20, int(round(24 * text_scale))) desc_to_render = status_text if status_text else fallback_desc - desc_size = max(15, int(round(16 * text_scale))) if desc_to_render else 0 + desc_size = max(17, int(round(18 * text_scale))) if desc_to_render else 0 icon_h = 0.0 if self.custom_icon_key: @@ -3156,7 +3389,7 @@ class ToggleTile(AetherTile): content_pad = SPACING.tile_content max_w = rw - content_pad * 2 text_scale = max(0.82, min(1.12, min(rw / 360.0, rh / 205.0))) - title_size = max(18, int(round(22 * text_scale))) + title_size = max(20, int(round(24 * text_scale))) if not enabled: self._draw_text_fit(self._font, self.title, @@ -3170,13 +3403,13 @@ class ToggleTile(AetherTile): else: title_color = rl.WHITE if active else _HUD_TEXT_DIM if self.desc: - desc_size = max(14, int(round(16 * text_scale))) + desc_size = max(16, int(round(18 * text_scale))) self._draw_text_fit(self._font, self.title, rl.Vector2(rx + content_pad, ry + int(rh * 0.28)), max_w, title_size, align_center=True, color=title_color) self._draw_text_fit(self._font_desc, self.desc, rl.Vector2(rx + content_pad, ry + int(rh * 0.50)), - max_w, desc_size, align_center=True, color=AetherListColors.SUBTEXT) + max_w, desc_size, align_center=True, color=rl.Color(255, 255, 255, 140)) led_cx = rx + rw // 2 led_cy = ry + int(rh * 0.78) else: @@ -3194,6 +3427,51 @@ class ToggleTile(AetherTile): rl.draw_ring(rl.Vector2(led_cx, led_cy), 5, 6, 0, 360, 24, rl.Color(70, 78, 95, 140)) +class RowToggleTile(ToggleTile): + def __init__( + self, + title: str, + get_state: Callable[[], bool], + set_state: Callable[[bool], None], + bg_color: rl.Color | str | None = None, + desc: str = "", + is_enabled: Callable[[], bool] | None = None, + disabled_label: str = "", + ): + super().__init__( + title=title, + get_state=get_state, + set_state=set_state, + bg_color=bg_color, + desc=desc, + is_enabled=is_enabled, + disabled_label=disabled_label, + show_led=True, + ) + + def _render(self, rect: rl.Rectangle): + enabled = self.enabled + active = self.get_state() + + status_text = tr("Enabled") if active else tr("Disabled") + status_color_override = None if active else rl.Color(160, 160, 175, 255) + + def draw_led(rx, ry, rw, rh, content_pad, accent): + led_radius_outer = int(rh * 0.10) + led_radius_inner = int(rh * 0.06) + led_cx = rx + rw - content_pad - led_radius_outer + led_cy = ry + rh / 2 + + if active: + rl.draw_circle(int(led_cx), int(led_cy), led_radius_outer, rl.Color(accent.r, accent.g, accent.b, 40)) + rl.draw_circle(int(led_cx), int(led_cy), led_radius_inner, accent) + else: + rl.draw_circle(int(led_cx), int(led_cy), led_radius_inner + 1, rl.Color(14, 16, 22, 255)) + rl.draw_ring(rl.Vector2(led_cx, led_cy), led_radius_inner - 1, led_radius_inner + 1, 0, 360, 24, rl.Color(70, 78, 95, 140)) + + self._render_luxury_grid_layout(rect, self.title, status_text, active, status_color_override, draw_led) + + class ValueTile(AetherTile): def __init__( self, @@ -3260,6 +3538,36 @@ class ValueTile(AetherTile): max_w, val_size, align_center=True, color=val_color) +class RowPanelTile(ValueTile): + def __init__( + self, + title: str, + on_click: Callable | None = None, + bg_color: rl.Color | str | None = None, + desc: str = "", + get_status: Callable[[], str] | None = None, + ): + super().__init__( + title=title, + get_value=get_status or (lambda: ""), + on_click=on_click, + bg_color=bg_color, + desc=desc, + ) + + def _render(self, rect: rl.Rectangle): + status_text = self.get_value() + + def draw_chevron(rx, ry, rw, rh, content_pad, accent): + chev_size = int(rh * 0.16) + cx = rx + rw - content_pad - chev_size / 2 + cy = ry + rh / 2 + chev_rect = rl.Rectangle(cx - chev_size, cy - chev_size, chev_size * 2, chev_size * 2) + draw_chevron_icon(chev_rect, rl.Color(160, 160, 175, 255), thickness=3.0, direction="right") + + self._render_luxury_grid_layout(rect, self.title, status_text, True, None, draw_chevron) + + class SliderTile(AetherTile): LONG_PRESS_THRESHOLD = 0.5 DRAG_THRESHOLD = 10 @@ -4477,7 +4785,7 @@ class AetherSegmentedControl(Widget): class TileGrid(Widget): - def __init__(self, columns: int | None = None, padding: int | None = None, uniform_width: bool = False, min_tile_width: int | None = None, tile_height: float | None = None, force_square: bool = False): + def __init__(self, columns: int | None = None, padding: int | None = None, uniform_width: bool = False, min_tile_width: int | None = None, tile_height: float | None = None, force_square: bool = False, carousel_rows: int | None = None, carousel_tile_width: float | None = None): super().__init__() self._columns = columns self._gap = padding if padding is not None else SPACING.tile_gap @@ -4486,6 +4794,8 @@ class TileGrid(Widget): self._min_tile_width = min_tile_width if min_tile_width is not None else MIN_TILE_WIDTH self._tile_height = tile_height self.force_square = force_square + self.carousel_rows = carousel_rows + self.carousel_tile_width = carousel_tile_width @property @@ -4550,6 +4860,11 @@ class TileGrid(Widget): if not self.tiles: return 0.0 count = len(self.tiles) + if self.carousel_rows and self.carousel_tile_width: + rows = self.carousel_rows + h = self._tile_height if self._tile_height is not None else 130.0 + return rows * h + self._gap * max(0, rows - 1) + rows = self.get_row_count(count, available_width=width) if self.force_square: cols = self.get_effective_column_count(width, count) @@ -4559,6 +4874,15 @@ class TileGrid(Widget): h = self._tile_height if self._tile_height is not None else 130.0 return rows * h + self.get_internal_gap_height(count, available_width=width) + def measure_width(self) -> float: + if not self.tiles: + return 0.0 + if not self.carousel_rows or not self.carousel_tile_width: + return 0.0 + count = len(self.tiles) + cols = (count + self.carousel_rows - 1) // self.carousel_rows + return cols * self.carousel_tile_width + max(0, cols - 1) * self._gap + def _render(self, rect: rl.Rectangle): rect = _snap_rect(rect) self.set_rect(rect) @@ -4566,6 +4890,25 @@ class TileGrid(Widget): return tiles_to_render = list(self.tiles) count = len(tiles_to_render) + + if self.carousel_rows and self.carousel_tile_width: + rows = self.carousel_rows + cols = (count + rows - 1) // rows + tile_w = self.carousel_tile_width + tile_h = self._tile_height if self._tile_height is not None else (rect.height - self._gap * (rows - 1)) / rows + + for i, tile in enumerate(tiles_to_render): + c = i // rows + r = i % rows + row_x = rect.x + c * (tile_w + self._gap) + row_y = rect.y + r * (tile_h + self._gap) + + parent_rect = getattr(self, "_parent_rect", None) + if parent_rect is not None and hasattr(tile, "set_parent_rect"): + tile.set_parent_rect(parent_rect) + tile.render(_snap_rect(rl.Rectangle(row_x, row_y, tile_w, tile_h))) + return + cols = self.get_effective_column_count(rect.width, count) rows = self.get_row_count(count, available_width=rect.width) if self.force_square: diff --git a/selfdrive/ui/layouts/settings/starpilot/appearance.py b/selfdrive/ui/layouts/settings/starpilot/appearance.py index 0a1f6d572..f8a4699f3 100644 --- a/selfdrive/ui/layouts/settings/starpilot/appearance.py +++ b/selfdrive/ui/layouts/settings/starpilot/appearance.py @@ -18,6 +18,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( SettingRow, SettingSection, AetherSettingsView, + AetherCategoryTileView, TileGrid, HubTile, draw_list_group_shell, @@ -67,203 +68,6 @@ def _theme_display_name(value: str) -> str: display += f" - by: {creator}" return display -# ═══════════════════════════════════════════════════════════════ -# Custom Category Settings Dialog -# ═══════════════════════════════════════════════════════════════ - -class AetherCategorySettingsDialog(Widget): - def __init__(self, title: str, rows: list[SettingRow], color: rl.Color | str = "#8B5CF6", style=None): - super().__init__() - self.title = title - self._rows = rows - self._color = hex_to_color(color) if isinstance(color, str) else color - self._style = style or PANEL_STYLE - self._font_title = gui_app.font(FontWeight.BOLD) - self._font_btn = gui_app.font(FontWeight.BOLD) - - self._scroll_panel = GuiScrollPanel2(horizontal=False) - self._scroll_offset = 0.0 - self._scroll_rect = rl.Rectangle(0, 0, 0, 0) - self._content_height = 0.0 - - self._interactive_rects: dict[str, rl.Rectangle] = {} - self._pressed_target: str | None = None - self._can_click = True - - self._ok_rect = rl.Rectangle(0, 0, 0, 0) - self._ok_offset = 0.0 - self._ok_target = 0.0 - self._is_pressed_ok = False - - def _interactive_state(self, target_id: str, rect: rl.Rectangle) -> tuple[bool, bool]: - self._interactive_rects[target_id] = rect - hovered = _point_hits(gui_app.last_mouse_event.pos, rect, self._scroll_rect, pad_x=6, pad_y=0) - return hovered, self._pressed_target == target_id - - def _target_at(self, mouse_pos: MousePos) -> str | None: - if rl.check_collision_point_rec(mouse_pos, self._ok_rect): - return "ok" - for target_id, rect in self._interactive_rects.items(): - if _point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0): - return target_id - return None - - def _handle_mouse_press(self, mouse_pos: MousePos): - target = self._target_at(mouse_pos) - self._pressed_target = target - self._can_click = True - if target == "ok": - self._is_pressed_ok = True - self._ok_target = 1.0 - - def _handle_mouse_event(self, mouse_event): - if not self._scroll_panel.is_touch_valid(): - self._can_click = False - return - if self._pressed_target is not None and self._target_at(mouse_event.pos) != self._pressed_target: - if self._pressed_target == "ok": - self._ok_target = 0.0 - self._is_pressed_ok = False - self._pressed_target = None - - def _handle_mouse_release(self, mouse_pos: MousePos): - target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None - if self._pressed_target is not None and self._pressed_target == target and self._can_click: - if target == "ok": - gui_app.pop_widget() - else: - row = None - for r in self._rows: - if f"{r.type}:{r.id}" == target: - row = r - break - if row is not None: - enabled = row.enabled() if row.enabled is not None else True - if enabled: - if row.on_click: - row.on_click() - elif row.type == "toggle" and row.set_state and row.get_state: - row.set_state(not row.get_state()) - - self._ok_target = 0.0 - self._is_pressed_ok = False - self._pressed_target = None - self._can_click = True - - def _render_row(self, rect: rl.Rectangle, row: SettingRow, is_last: bool): - target_id = f"{row.type}:{row.id}" - hovered, pressed = self._interactive_state(target_id, rect) - - enabled = row.enabled() if row.enabled is not None else True - subtitle = row.disabled_label if not enabled and row.disabled_label else row.subtitle - - from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( - draw_standard_toggle_row, - draw_settings_list_row, - draw_selection_list_row, - ) - - if row.type == "toggle": - toggle_value = row.get_state() if row.get_state else False - draw_standard_toggle_row( - rect, tr(row.title), tr(subtitle), toggle_value, - enabled=enabled, hovered=hovered, pressed=pressed, - is_last=is_last, style=self._style, - ) - elif row.type == "value": - value_text = row.get_value() if row.get_value else "" - draw_settings_list_row( - rect, - title=tr(row.title), - subtitle=tr(subtitle), - value=value_text, - enabled=enabled, - hovered=hovered, - pressed=pressed, - is_last=is_last, - show_chevron=row.on_click is not None, - title_size=34, subtitle_size=22, value_size=28, - style=self._style, - ) - elif row.type == "action": - action_fill = self._style.danger_fill if row.action_danger else self._style.current_fill - action_border = self._style.danger_border if row.action_danger else self._style.current_border - action_text_color = self._style.danger_text if row.action_danger else AetherListColors.HEADER - draw_selection_list_row( - rect, - title=tr(row.title), - subtitle=tr(subtitle), - action_text=tr(row.action_text), - hovered=hovered, - pressed=pressed, - is_last=is_last, - action_pill=True, - title_size=34, subtitle_size=22, - action_pill_height=44, action_text_size=18, - action_text_color=action_text_color, - action_fill=action_fill, - action_border=action_border, - row_separator=self._style.divider_color, - ) - - def _render(self, rect: rl.Rectangle): - dt = rl.get_frame_time() - self._ok_offset += (self._ok_target - self._ok_offset) * (1 - math.exp(-dt / PLATE_TAU)) - rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 160)) - - self._interactive_rects.clear() - - dialog_w = 1600 - dialog_h = 840 - button_height = 110 - button_width = 600 - - dx, dy = rect.x + (rect.width - dialog_w) / 2, rect.y + (rect.height - dialog_h) / 2 - self._ok_rect = rl.Rectangle(dx + (dialog_w - button_width) / 2, dy + dialog_h - button_height - 60, button_width, button_height) - - 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) - - title_size = 44 - ts = measure_text_cached(self._font_title, self.title, title_size) - rl.draw_text_ex(self._font_title, self.title, rl.Vector2(round(dx + (dialog_w - ts.x) / 2), round(dy + 60)), title_size, 0, rl.WHITE) - - scroll_x = dx + 80 - scroll_y = dy + 150 - scroll_w = dialog_w - 160 - scroll_h = dialog_h - 150 - button_height - 100 - self._scroll_rect = rl.Rectangle(scroll_x, scroll_y, scroll_w, scroll_h) - - row_height = 122 - visible_rows = [r for r in self._rows if r.visible is None or r.visible()] - self._content_height = len(visible_rows) * row_height - - self._scroll_panel.set_enabled(self.is_visible) - self._scroll_offset = self._scroll_panel.update(self._scroll_rect, max(self._content_height, scroll_h)) - - rl.begin_scissor_mode(int(scroll_x), int(scroll_y), int(scroll_w), int(scroll_h)) - y = scroll_y + self._scroll_offset - - if visible_rows: - group_rect = rl.Rectangle(scroll_x, y, scroll_w, self._content_height) - draw_list_group_shell(group_rect, style=self._style) - - for i, row in enumerate(visible_rows): - row_rect = rl.Rectangle(scroll_x, y + i * row_height, scroll_w, row_height) - self._render_row(row_rect, row, is_last=(i == len(visible_rows) - 1)) - - rl.end_scissor_mode() - - o_off = self._ok_offset * 4.0 - o_rect = rl.Rectangle(self._ok_rect.x + o_off, self._ok_rect.y + o_off, self._ok_rect.width - o_off * 2, self._ok_rect.height - o_off * 2) - _draw_rounded_fill(o_rect, self._color, radius_px=button_height / 2) - _draw_rounded_stroke(o_rect, _mix_colors(self._color, rl.WHITE, 0.4, alpha=160), thickness=2, radius_px=button_height / 2) - ots = measure_text_cached(self._font_btn, tr("OK"), 38) - rl.draw_text_ex(self._font_btn, tr("OK"), rl.Vector2(round(o_rect.x + (o_rect.width - ots.x) / 2), round(o_rect.y + (o_rect.height - ots.y) / 2)), 38, 0, rl.WHITE) - - # ═══════════════════════════════════════════════════════════════ # Unified Appearance panel # ═══════════════════════════════════════════════════════════════ @@ -293,7 +97,14 @@ class AppearanceManagerView(AetherSettingsView): "icon": "steering", "color": "#8B5CF6", "on_click": lambda: gui_app.push_widget( - AetherCategorySettingsDialog(tr("Model & Path Visualization"), self._controller._model_rows, color="#8B5CF6", style=self._panel_style) + 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, + ) ) }, { @@ -302,7 +113,14 @@ class AppearanceManagerView(AetherSettingsView): "icon": "display", "color": "#8B5CF6", "on_click": lambda: gui_app.push_widget( - AetherCategorySettingsDialog(tr("Driving Widgets & HUD"), self._controller._hud_rows, color="#8B5CF6", style=self._panel_style) + 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, + ) ) }, { @@ -311,7 +129,14 @@ class AppearanceManagerView(AetherSettingsView): "icon": "system", "color": "#8B5CF6", "on_click": lambda: gui_app.push_widget( - AetherCategorySettingsDialog(tr("Screen Declutter & Visibility"), self._controller._declutter_rows, color="#8B5CF6", style=self._panel_style) + 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, + ) ) }, ] @@ -323,7 +148,14 @@ class AppearanceManagerView(AetherSettingsView): "icon": "navigate", "color": "#8B5CF6", "on_click": lambda: gui_app.push_widget( - AetherCategorySettingsDialog(tr("Navigation & Mapping"), self._controller._nav_rows, color="#8B5CF6", style=self._panel_style) + 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, + ) ) }, { @@ -332,7 +164,14 @@ class AppearanceManagerView(AetherSettingsView): "icon": "vehicle", "color": "#8B5CF6", "on_click": lambda: gui_app.push_widget( - AetherCategorySettingsDialog(tr("Camera & System Startup"), self._controller._system_rows, color="#8B5CF6", style=self._panel_style) + 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, + ) ) }, { @@ -341,7 +180,14 @@ class AppearanceManagerView(AetherSettingsView): "icon": "sound", "color": "#8B5CF6", "on_click": lambda: gui_app.push_widget( - AetherCategorySettingsDialog(tr("Developer & Beta Metrics"), self._controller._dev_rows, color="#8B5CF6", style=self._panel_style) + AetherCategoryTileView( + self._controller, + tr("Developer & Beta Metrics"), + self._controller._dev_rows, + color="#8B5CF6", + subtitle=tr("Adjust radar plots, lead vehicle info, and stop sign metrics."), + panel_style=self._panel_style, + ) ) }, ] diff --git a/selfdrive/ui/tests/test_aethergrid.py b/selfdrive/ui/tests/test_aethergrid.py index b518498b6..b1b0f499a 100644 --- a/selfdrive/ui/tests/test_aethergrid.py +++ b/selfdrive/ui/tests/test_aethergrid.py @@ -45,7 +45,7 @@ def _install_aethergrid_stubs(): draw_triangle=lambda *a, **k: None, draw_texture_pro=lambda *a, **k: None, draw_text_ex=lambda *a, **k: None, - check_collision_point_rec=lambda *a, **k: False, + check_collision_point_rec=lambda p, r: (r.x <= p.x <= r.x + r.width) and (r.y <= p.y <= r.y + r.height), get_frame_time=lambda: 0.016, get_mouse_position=lambda: types.SimpleNamespace(x=0, y=0), ) @@ -100,6 +100,11 @@ def _install_aethergrid_stubs(): self._parent_rect = None self._enabled = True self.is_pressed = False + self._children = [] + + def _child(self, widget): + self._children.append(widget) + return widget @property def enabled(self): @@ -118,6 +123,9 @@ def _install_aethergrid_stubs(): def set_click_callback(self, callback): self.on_click = callback + def set_touch_valid_callback(self, callback): + self._touch_valid_callback = callback + def set_enabled(self, enabled): self._enabled = enabled @@ -520,6 +528,46 @@ class TestAethergridContracts(unittest.TestCase): except OverflowError: self.fail("OverflowError raised with extreme glow values") + def test_aether_category_tile_view(self): + mod = _import_aethergrid() + controller_mock = MagicMock() + + toggle_visible = True + rows = [ + mod.SettingRow("toggle_row", "toggle", "Toggle Title", subtitle="Toggle Subtitle", + get_state=lambda: True, set_state=lambda v: None, + visible=lambda: toggle_visible), + mod.SettingRow("value_row", "value", "Value Title", subtitle="Value Subtitle", + get_value=lambda: "Value", on_click=lambda: None), + mod.SettingRow("action_row", "action", "Action Title", action_text="Run", on_click=lambda: None), + ] + + 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) + + view._update_visible_tiles() + self.assertEqual(len(view._tile_grid.tiles), 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) + + view._back_btn_rect = mod.rl.Rectangle(196, 56, 68, 68) + + self.assertEqual(view._target_at(mod.rl.Vector2(200, 60)), "static:back") + self.assertNotEqual(view._target_at(mod.rl.Vector2(0, 0)), "static:back") + + app_mod = sys.modules["openpilot.system.ui.lib.application"] + app_mod.gui_app.pop_widget = MagicMock() + + view._activate_target("static:back") + app_mod.gui_app.pop_widget.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index 542ecfe25..cf3c78a25 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -56,6 +56,8 @@ class GuiScrollPanel2: self._velocity = 0.0 # pixels per second self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6) self._enabled: bool | Callable[[], bool] = True + self.snap_interval: float | None = None + self._snap_target: float | None = None def set_enabled(self, enabled: bool | Callable[[], bool]) -> None: self._enabled = enabled @@ -103,30 +105,52 @@ class GuiScrollPanel2: # if we find ourselves out of bounds, scroll back in (from external layout dimension changes, etc.) if self.get_offset() > max_offset or self.get_offset() < min_offset: self._state = ScrollState.AUTO_SCROLL + elif self.snap_interval is not None and self.snap_interval > 0: + col_step = self.snap_interval + current_offset = self.get_offset() + idx = -current_offset / col_step + rounded_idx = round(idx) + if abs(current_offset - (-rounded_idx * col_step)) > 0.1: + max_col_idx = int(round(-min_offset / col_step)) + target_idx = max(0, min(rounded_idx, max_col_idx)) + self._snap_target = max(min_offset, min(0.0, -target_idx * col_step)) + self._state = ScrollState.AUTO_SCROLL elif self._state == ScrollState.AUTO_SCROLL: - # simple exponential return if out of bounds - out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset - if out_of_bounds and self._handle_out_of_bounds: - target = max_offset if self.get_offset() > max_offset else min_offset - + if self.snap_interval is not None and self._snap_target is not None: + target = self._snap_target dt = rl.get_frame_time() or 1e-6 - factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt) - + factor = 1.0 - math.exp(-12.0 * dt) dist = target - self.get_offset() - self.set_offset(self.get_offset() + dist * factor) # ease toward the edge - self._velocity *= (1.0 - factor) # damp any leftover fling - - # Steady once we are close enough to the target - if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY: + self.set_offset(self.get_offset() + dist * factor) + self._velocity = 0.0 + if abs(dist) < 0.5: self.set_offset(target) + self._snap_target = None + self._state = ScrollState.STEADY + else: + # simple exponential return if out of bounds + out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset + if out_of_bounds and self._handle_out_of_bounds: + target = max_offset if self.get_offset() > max_offset else min_offset + + dt = rl.get_frame_time() or 1e-6 + factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt) + + dist = target - self.get_offset() + self.set_offset(self.get_offset() + dist * factor) # ease toward the edge + self._velocity *= (1.0 - factor) # damp any leftover fling + + # Steady once we are close enough to the target + if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY: + self.set_offset(target) + self._velocity = 0.0 + self._state = ScrollState.STEADY + + elif abs(self._velocity) < MIN_VELOCITY: self._velocity = 0.0 self._state = ScrollState.STEADY - elif abs(self._velocity) < MIN_VELOCITY: - self._velocity = 0.0 - self._state = ScrollState.STEADY - # Update the offset based on the current velocity dt = rl.get_frame_time() self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity @@ -135,6 +159,9 @@ class GuiScrollPanel2: def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float, content_size: float) -> None: + if mouse_event.left_pressed: + self._snap_target = None + max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size) # simple exponential return if out of bounds out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset @@ -198,16 +225,34 @@ class GuiScrollPanel2: self._velocity = weighted_velocity(self._velocity_buffer) - # If final velocity is below some threshold, switch to steady state too - low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin - - if out_of_bounds or not (high_decel or low_speed): + if self.snap_interval is not None and self.snap_interval > 0: + col_step = self.snap_interval + current_offset = self.get_offset() + idx = -current_offset / col_step + fling_threshold = 250.0 + if self._velocity > fling_threshold: + target_idx = math.floor(idx) + elif self._velocity < -fling_threshold: + target_idx = math.ceil(idx) + else: + target_idx = round(idx) + max_col_idx = int(round(-min_offset / col_step)) + target_idx = max(0, min(target_idx, max_col_idx)) + self._snap_target = max(min_offset, min(0.0, -target_idx * col_step)) self._state = ScrollState.AUTO_SCROLL - else: - # TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares self._velocity = 0.0 - self._state = ScrollState.STEADY - self._velocity_buffer.clear() + self._velocity_buffer.clear() + else: + # If final velocity is below some threshold, switch to steady state too + low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin + + if out_of_bounds or not (high_decel or low_speed): + self._state = ScrollState.AUTO_SCROLL + else: + # TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares + self._velocity = 0.0 + self._state = ScrollState.STEADY + self._velocity_buffer.clear() else: # Update velocity for when we release the mouse button. # Do not update velocity on the same frame the mouse was released