diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index 86255888c..3ea4bd320 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -589,6 +589,17 @@ class PanelManagerView(AetherInteractiveMixin, Widget): disabled_label=d.get("disabled_label", ""), ) + def _make_multi_select_tile(self, d: dict) -> AetherMultiSelectTile: + return AetherMultiSelectTile( + title=d["title"], + options=d["options"], + get_values=d.get("get_values", d.get("get")), + set_values=d.get("set_values", d.get("set")), + bg_color=self.PANEL_STYLE.accent, + desc=d.get("subtitle", d.get("desc", "")), + is_enabled=d.get("is_enabled", d.get("enabled")), + ) + # ── pagination ───────────────────────────────────────────── PAGE_COMMIT_RATIO = 0.20 @@ -3975,6 +3986,275 @@ class RadioTileGroup(Widget): rl.draw_text_ex(self._font, opt, rl.Vector2(round(text_pos.x), round(text_pos.y)), font_size, spacing, AetherListColors.HEADER if is_active else AetherListColors.SUBTEXT) +class AetherMultiSelectDialog(Widget): + def __init__( + self, + title: str, + options: dict[str, str], + current_values: list[str], + on_close: Callable, + color: rl.Color | str = "#8B5CF6", + ): + super().__init__() + self.title = title + self._user_callback = on_close + self._options = options + self._selected = set(current_values) + self._color = hex_to_color(color) if isinstance(color, str) else color + self._font_title = gui_app.font(FontWeight.BOLD) + self._font_btn = gui_app.font(FontWeight.BOLD) + self._font_chip = gui_app.font(FontWeight.MEDIUM) + self._chip_rects: list[tuple[str, rl.Rectangle]] = [] + + self._pressed_zone: str | None = None + self._is_pressed_ok = False + self._is_pressed_cancel = False + self._ok_offset = 0.0 + self._cancel_offset = 0.0 + self._ok_target = 0.0 + self._cancel_target = 0.0 + + self._ok_rect = rl.Rectangle(0, 0, 0, 0) + self._cancel_rect = rl.Rectangle(0, 0, 0, 0) + + def _handle_mouse_press(self, mouse_pos: MousePos): + if rl.check_collision_point_rec(mouse_pos, self._ok_rect): + self._is_pressed_ok = True + self._ok_target = 1.0 + return + if rl.check_collision_point_rec(mouse_pos, self._cancel_rect): + self._is_pressed_cancel = True + self._cancel_target = 1.0 + return + + for slug, rect in self._chip_rects: + if rl.check_collision_point_rec(mouse_pos, rect): + self._pressed_zone = f"chip:{slug}" + return + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._is_pressed_ok: + self._ok_target = 0.0 + if rl.check_collision_point_rec(mouse_pos, self._ok_rect): + gui_app.pop_widget() + self._user_callback(DialogResult.CONFIRM, list(self._selected)) + self._is_pressed_ok = False + elif self._is_pressed_cancel: + self._cancel_target = 0.0 + if rl.check_collision_point_rec(mouse_pos, self._cancel_rect): + gui_app.pop_widget() + self._user_callback(DialogResult.CANCEL, list(self._selected)) + self._is_pressed_cancel = False + elif self._pressed_zone: + if self._pressed_zone.startswith("chip:"): + slug = self._pressed_zone.split(":", 1)[1] + for s, rect in self._chip_rects: + if s == slug and rl.check_collision_point_rec(mouse_pos, rect): + if slug in self._selected: + self._selected.remove(slug) + else: + self._selected.add(slug) + break + self._pressed_zone = None + + def _handle_mouse_event(self, mouse_event): + pass + + def _render_chip(self, rect: rl.Rectangle, text: str, *, current: bool, pressed: bool): + fill = rl.Color(255, 255, 255, 5) + border = rl.Color(255, 255, 255, 14) + text_color = AetherListColors.SUBTEXT + if current: + fill = _mix_colors(rl.Color(18, 22, 28, 255), self._color, 0.22, alpha=255) + border = _with_alpha(self._color, 72) + text_color = AetherListColors.HEADER + elif pressed: + fill = rl.Color(255, 255, 255, 10) + border = rl.Color(255, 255, 255, 22) + + _draw_rounded_fill(rect, fill, radius_px=16) + _draw_rounded_stroke(rect, border, radius_px=16) + _draw_text_fit_common( + self._font_chip, + text, + rl.Vector2(rect.x + 10, rect.y + (rect.height - 28) / 2), + max(1.0, rect.width - 20), + 28, + align_center=True, + color=text_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)) + self._cancel_offset += (self._cancel_target - self._cancel_offset) * (1 - math.exp(-dt / PLATE_TAU)) + rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 160)) + + 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 - 80, dy + dialog_h - button_height - 60, button_width, button_height) + self._cancel_rect = rl.Rectangle(dx + 80, 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) + + # Render options grid + chip_h = 84.0 + chip_gap_x = 24.0 + chip_gap_y = 24.0 + chips_per_row = 4 + + total_chips = len(self._options) + + if total_chips > 0: + chip_w = max(90.0, (dialog_w - 80 * 2 - chip_gap_x * (chips_per_row - 1)) / chips_per_row) + + start_y = dy + 160 + self._chip_rects.clear() + for index, (slug, display_name) in enumerate(self._options.items()): + row = index // chips_per_row + col = index % chips_per_row + chip_x = dx + 80 + col * (chip_w + chip_gap_x) + chip_y = start_y + row * (chip_h + chip_gap_y) + chip_rect = _snap_rect(rl.Rectangle(chip_x, chip_y, chip_w, chip_h)) + self._chip_rects.append((slug, chip_rect)) + + self._render_chip( + chip_rect, + display_name, + current=slug in self._selected, + pressed=self._pressed_zone == f"chip:{slug}", + ) + + # Cancel Button + c_off = self._cancel_offset * 4.0 + c_rect = rl.Rectangle(self._cancel_rect.x + c_off, self._cancel_rect.y + c_off, self._cancel_rect.width - c_off * 2, self._cancel_rect.height - c_off * 2) + _draw_rounded_fill(c_rect, rl.Color(255, 255, 255, 12), radius_px=button_height / 2) + _draw_rounded_stroke(c_rect, rl.Color(255, 255, 255, 32), radius_px=button_height / 2) + cts = measure_text_cached(self._font_btn, tr("Cancel"), 38) + rl.draw_text_ex(self._font_btn, tr("Cancel"), rl.Vector2(round(c_rect.x + (c_rect.width - cts.x) / 2), round(c_rect.y + (c_rect.height - cts.y) / 2)), 38, 0, rl.WHITE) + + # OK Button + 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) + + +class AetherMultiSelectTile(AetherTile): + def __init__( + self, + title: str, + options: dict[str, str], + get_values: Callable[[], list[str]], + set_values: Callable[[list[str]], None], + bg_color: rl.Color | str | None = None, + is_enabled: Callable[[], bool] | None = None, + desc: str = "", + ): + super().__init__(surface_color=bg_color) + self.title = title + self.desc = desc + self._options = options + self.get_values = get_values + self.set_values = set_values + self.set_enabled(is_enabled or (lambda: True)) + self._font = gui_app.font(FontWeight.BOLD) + self._font_desc = gui_app.font(FontWeight.NORMAL) + self._active_color = self.surface_color + self._disabled_color = rl.Color(120, 120, 120, 255) + self._squish: float = 1.0 + + def _update_state(self): + dt = rl.get_frame_time() + if self._squish < 1.0: + self._squish += (1.0 - self._squish) * 15.0 * dt + + def _handle_mouse_press(self, mouse_pos: MousePos): + if not self.enabled: + return + if rl.check_collision_point_rec(mouse_pos, self._hit_rect): + self._is_pressed = True + self._plate_target = 1.0 + self._squish = 0.95 + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._is_pressed: + if rl.check_collision_point_rec(mouse_pos, self._hit_rect) and self.enabled: + def on_close(res, new_values): + if res == DialogResult.CONFIRM: + self.set_values(new_values) + dialog = AetherMultiSelectDialog( + title=self.title, + options=self._options, + current_values=self.get_values(), + on_close=on_close, + color=self._active_color, + ) + gui_app.push_widget(dialog) + self._plate_target = 0.0 + self._is_pressed = False + + def _render(self, rect: rl.Rectangle): + enabled = self.enabled + self._animate_plate(rl.get_frame_time()) + + if not enabled: + self._plate_offset = 0.0 + self._plate_target = 0.0 + + color = self._active_color if enabled else self._disabled_color + glow = 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 = SPACING.tile_content + max_w = rw - content_pad * 2 + text_scale = min(rw / 360.0, rh / 205.0) + + title_size = max(18, int(round(22 * text_scale))) + self._draw_text_fit(self._font, self.title, + rl.Vector2(rx + content_pad, ry + int(rh * 0.15)), + max_w, title_size, align_center=True, color=_HUD_TEXT_DIM) + + current_values = self.get_values() + val_size = max(14, int(round(18 * text_scale))) + val_color = rl.WHITE if enabled else _HUD_TEXT_DIM + + list_start_y = ry + int(rh * 0.40) + line_spacing = max(18, int(round(26 * text_scale))) + + for i, val_slug in enumerate(current_values): + if i >= 4: + break + y = list_start_y + i * line_spacing + name = self._options.get(val_slug, val_slug) + + led_cx = rx + content_pad + 10 + led_cy = y + val_size // 2 + 1 + if enabled: + rl.draw_circle(int(led_cx), int(led_cy), 8, rl.Color(accent.r, accent.g, accent.b, 24)) + rl.draw_circle(int(led_cx), int(led_cy), 4, accent) + else: + rl.draw_circle(int(led_cx), int(led_cy), 5, rl.Color(14, 16, 22, 255)) + rl.draw_ring(rl.Vector2(led_cx, led_cy), 3, 4, 0, 360, 24, rl.Color(70, 78, 95, 140)) + + self._draw_text_fit(self._font_desc, name, + rl.Vector2(rx + content_pad + 26, y), + max_w - 26, val_size, align_center=False, color=val_color) + class AetherSegmentedControl(Widget): def __init__( self,