From ed695fab1b97c8cbcc85a3595fe216c7352ce644 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:17:13 -0400 Subject: [PATCH] BigUI WIP: Simpleton --- .../layouts/settings/starpilot/aethergrid.py | 842 +++++++++++------- .../settings/starpilot/sectioned_panel.py | 87 +- .../settings/starpilot/system_settings.py | 182 ++-- selfdrive/ui/tests/test_aethergrid.py | 359 ++++++++ 4 files changed, 1072 insertions(+), 398 deletions(-) create mode 100644 selfdrive/ui/tests/test_aethergrid.py diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index 1dd320a20..0b55e0a44 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -14,9 +14,13 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader import starp GEOMETRY_OFFSET = 10 PLATE_TAU = 0.060 -TILE_RADIUS = 0.25 -TILE_SEGMENTS = 10 +TILE_RADIUS = 0.08 +TILE_SEGMENTS = 12 SLIDER_BUTTON_SIZE = 60 +TILE_INSET = 1.0 +TILE_RADIUS_PX = 18.0 +TILE_SIGNAL_WIDTH = 1 +MIN_TILE_WIDTH = 300 class SPACING: @@ -41,18 +45,6 @@ def hex_to_color(hex_str: str) -> rl.Color: return rl.Color(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16), 255) -_SUBSTRATE_MAP = { - "#E63956": hex_to_color("#5A0B1A"), - "#3B82F6": hex_to_color("#0B1C4A"), - "#10B981": hex_to_color("#033326"), - "#D946EF": hex_to_color("#4A052E"), - "#8B5CF6": hex_to_color("#1E0A4D"), - "#64748B": hex_to_color("#252A33"), -} - -_DEFAULT_SUBSTRATE = hex_to_color("#1E0A4D") - - def _resolve_value(value, default=""): if callable(value): return value() @@ -63,6 +55,75 @@ def _with_alpha(color: rl.Color, alpha: int) -> rl.Color: return rl.Color(color.r, color.g, color.b, max(0, min(color.a, int(alpha)))) +def _mix_colors(base: rl.Color, accent: rl.Color, weight: float, alpha: int | None = None) -> rl.Color: + w = max(0.0, min(1.0, weight)) + return rl.Color( + int(round(base.r + (accent.r - base.r) * w)), + int(round(base.g + (accent.g - base.g) * w)), + int(round(base.b + (accent.b - base.b) * w)), + base.a if alpha is None else alpha, + ) + + +def _tone_step(color: rl.Color, delta: int, alpha: int | None = None) -> rl.Color: + return rl.Color( + max(0, min(255, color.r + delta)), + max(0, min(255, color.g + delta)), + max(0, min(255, color.b + delta)), + color.a if alpha is None else alpha, + ) + + +def _default_substrate_for(color: rl.Color) -> rl.Color: + return _mix_colors(rl.Color(14, 17, 23, 255), color, 0.14) + + +def _snap(value: float) -> float: + return float(round(value)) + + +def _snap_rect(rect: rl.Rectangle) -> rl.Rectangle: + x = _snap(rect.x) + y = _snap(rect.y) + right = _snap(rect.x + rect.width) + bottom = _snap(rect.y + rect.height) + return rl.Rectangle(x, y, max(0.0, right - x), max(0.0, bottom - y)) + + +def _inset_rect(rect: rl.Rectangle, inset: float) -> rl.Rectangle: + return _snap_rect(rl.Rectangle(rect.x + inset, rect.y + inset, rect.width - inset * 2, rect.height - inset * 2)) + + +def _intersect_rect(a: rl.Rectangle, b: rl.Rectangle) -> rl.Rectangle: + left = max(a.x, b.x) + top = max(a.y, b.y) + right = min(a.x + a.width, b.x + b.width) + bottom = min(a.y + a.height, b.y + b.height) + if right <= left or bottom <= top: + return rl.Rectangle(left, top, 0, 0) + return rl.Rectangle(left, top, right - left, bottom - top) + + +def _roundness_for(rect: rl.Rectangle, radius_px: float = TILE_RADIUS_PX) -> float: + min_dim = max(1.0, min(rect.width, rect.height)) + return max(0.0, min(0.5, radius_px / min_dim)) + + +def _segments_for(rect: rl.Rectangle, radius_px: float = TILE_RADIUS_PX) -> int: + effective_radius = max(2.0, min(radius_px, min(rect.width, rect.height) / 2)) + return max(12, min(28, int(round(effective_radius * 1.25)))) + + +def _draw_rounded_fill(rect: rl.Rectangle, color: rl.Color, radius_px: float = TILE_RADIUS_PX): + snapped = _snap_rect(rect) + rl.draw_rectangle_rounded(snapped, _roundness_for(snapped, radius_px), _segments_for(snapped, radius_px), color) + + +def _draw_rounded_stroke(rect: rl.Rectangle, color: rl.Color, thickness: int = 1, radius_px: float = TILE_RADIUS_PX): + snapped = _snap_rect(rect) + rl.draw_rectangle_rounded_lines_ex(snapped, _roundness_for(snapped, radius_px), _segments_for(snapped, radius_px), thickness, color) + + class AetherListColors: PANEL_BG = rl.Color(8, 8, 10, 255) PANEL_BORDER = rl.Color(255, 255, 255, 22) @@ -155,15 +216,17 @@ def build_list_panel_frame(rect: rl.Rectangle, metrics: AetherListMetrics = AETH def draw_list_panel_shell(frame: AetherListFrame, *, bg: rl.Color = AetherListColors.PANEL_BG, border: rl.Color = AetherListColors.PANEL_BORDER, glow: rl.Color = AetherListColors.PANEL_GLOW): - rl.draw_rectangle_rounded(frame.shell, 0.055, 18, bg) - rl.draw_rectangle_rounded_lines_ex(frame.shell, 0.055, 18, 1, border) - glow_rect = rl.Rectangle(frame.shell.x + 2, frame.shell.y + 2, frame.shell.width - 4, frame.shell.height - 4) - rl.draw_rectangle_rounded_lines_ex(glow_rect, 0.055, 18, 1, glow) + shell = _snap_rect(frame.shell) + _draw_rounded_fill(shell, bg, radius_px=22) + _draw_rounded_stroke(shell, border, radius_px=22) + if glow.a > 0: + glow_rect = _inset_rect(shell, 2) + _draw_rounded_stroke(glow_rect, _with_alpha(glow, 14), radius_px=20) def draw_soft_card(rect: rl.Rectangle, fill: rl.Color, border: rl.Color, radius: float = 0.08, segments: int = 18): - rl.draw_rectangle_rounded(rect, radius, segments, fill) - rl.draw_rectangle_rounded_lines_ex(rect, radius, segments, 1, border) + _draw_rounded_fill(rect, fill) + _draw_rounded_stroke(rect, border) def draw_list_row_shell( @@ -293,21 +356,18 @@ def draw_action_pill( ): rl.draw_rectangle_rounded(rect, roundness, segments, fill) rl.draw_rectangle_rounded_lines_ex(rect, roundness, segments, 1, border) + rl.draw_rectangle_rec(rl.Rectangle(rect.x, rect.y, rect.width, 1), _with_alpha(text_color, 18)) gui_label(rect, text, font_size, text_color, FontWeight.SEMI_BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) def draw_status_led(center: rl.Vector2, enabled: bool): if enabled: led_color = rl.Color(110, 175, 245, 255) - rl.draw_circle(int(center.x), int(center.y), 18, rl.Color(110, 175, 245, 18)) - rl.draw_circle(int(center.x), int(center.y), 12, rl.Color(110, 175, 245, 48)) - rl.draw_circle(int(center.x), int(center.y), 7, rl.Color(110, 175, 245, 100)) + rl.draw_circle(int(center.x), int(center.y), 11, rl.Color(110, 175, 245, 24)) rl.draw_circle(int(center.x), int(center.y), 6, led_color) - rl.draw_circle(int(center.x - 2), int(center.y - 2), 2, rl.Color(210, 235, 255, 200)) else: - rl.draw_circle(int(center.x), int(center.y), 8, rl.Color(10, 10, 14, 230)) - rl.draw_circle(int(center.x), int(center.y), 6, rl.Color(35, 40, 50, 255)) - rl.draw_ring(center, 6, 7, 0, 360, 24, rl.Color(70, 78, 95, 140)) + rl.draw_circle(int(center.x), int(center.y), 7, rl.Color(14, 16, 22, 255)) + rl.draw_ring(center, 5, 6, 0, 360, 24, rl.Color(70, 78, 95, 140)) def draw_overflow_dots(center: rl.Vector2, color: rl.Color): @@ -397,8 +457,9 @@ class AetherButton(Widget): if pressed: bg = rl.Color(max(bg.r - 8, 0), max(bg.g - 8, 0), max(bg.b - 8, 0), bg.a) - rl.draw_rectangle_rounded(rect, 0.32, 16, bg) - rl.draw_rectangle_rounded_lines_ex(rect, 0.32, 16, 1, border) + rl.draw_rectangle_rounded(rect, 0.18, 12, bg) + rl.draw_rectangle_rounded_lines_ex(rect, 0.18, 12, 1, border) + rl.draw_rectangle_rec(rl.Rectangle(rect.x, rect.y, rect.width, 1), _with_alpha(AetherListColors.HEADER, 18 if enabled else 8)) gui_label( rect, self.text, @@ -466,35 +527,47 @@ class AetherTile(Widget): super().__init__() if isinstance(surface_color, str): self.surface_color = hex_to_color(surface_color) - self.substrate_color = _SUBSTRATE_MAP.get(surface_color, _DEFAULT_SUBSTRATE) elif surface_color: self.surface_color = surface_color - self.substrate_color = substrate_color or _DEFAULT_SUBSTRATE else: self.surface_color = hex_to_color("#3B82F6") - self.substrate_color = hex_to_color("#0B1C4A") - if isinstance(self.substrate_color, str): - self.substrate_color = hex_to_color(self.substrate_color) + if isinstance(substrate_color, str): + self.substrate_color = hex_to_color(substrate_color) + else: + self.substrate_color = substrate_color or _default_substrate_for(self.surface_color) self.on_click = on_click self._plate_offset: float = 0.0 self._plate_target: float = 0.0 self._is_pressed: bool = False + def _surface_rect(self, rect: rl.Rectangle) -> rl.Rectangle: + return _inset_rect(_snap_rect(rect), TILE_INSET) + @property def _hit_rect(self) -> rl.Rectangle: - return rl.Rectangle( + hit_rect = rl.Rectangle( self._rect.x - GEOMETRY_OFFSET, self._rect.y - GEOMETRY_OFFSET, self._rect.width + 2 * GEOMETRY_OFFSET, self._rect.height + 2 * GEOMETRY_OFFSET, ) + parent_rect = getattr(self, "_parent_rect", None) + if parent_rect is not None: + return _intersect_rect(hit_rect, parent_rect) + return hit_rect 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 def _handle_mouse_release(self, mouse_pos: MousePos): + if not self.enabled: + self._is_pressed = False + self._plate_target = 0.0 + return if self._is_pressed: if rl.check_collision_point_rec(mouse_pos, self._hit_rect): self._plate_target = 0.0 @@ -515,28 +588,22 @@ class AetherTile(Widget): def _render_layers(self, rect: rl.Rectangle, radius: float = TILE_RADIUS, segments: int = TILE_SEGMENTS): self._animate_plate(rl.get_frame_time()) - self.set_rect(rect) + snapped_rect = _snap_rect(rect) + self.set_rect(snapped_rect) - extrusion_alpha = int(255 * (1.0 - 0.9 * self._plate_offset)) - substrate = rl.Color(self.substrate_color.r, self.substrate_color.g, self.substrate_color.b, extrusion_alpha) - rl.draw_rectangle_rounded(rl.Rectangle(rect.x + GEOMETRY_OFFSET, rect.y + GEOMETRY_OFFSET, rect.width, rect.height), radius, segments, substrate) + surface_rect = self._surface_rect(snapped_rect) + base_color = _mix_colors(self.substrate_color, self.surface_color, 0.10) + surface_color = _mix_colors(AetherListColors.PANEL_BG, self.surface_color, 0.16 if self.enabled else 0.08) + if self._is_pressed: + surface_color = _tone_step(surface_color, -8) + border_color = _mix_colors(AetherListColors.PANEL_BORDER, self.surface_color, 0.38, alpha=84 if self.enabled else 42) - face_x = rect.x + GEOMETRY_OFFSET * self._plate_offset - face_y = rect.y + GEOMETRY_OFFSET * self._plate_offset + _draw_rounded_fill(snapped_rect, base_color) + _draw_rounded_fill(surface_rect, surface_color) + _draw_rounded_stroke(surface_rect, border_color) + rl.draw_rectangle_rec(rl.Rectangle(surface_rect.x, surface_rect.y + surface_rect.height - 1, surface_rect.width, 1), _with_alpha(border_color, 48)) - shadow_rect = rl.Rectangle(face_x, face_y, rect.width, rect.height) - rl.draw_rectangle_rounded(shadow_rect, radius, segments, rl.Color(0, 0, 0, 80)) - - hl_rect = rl.Rectangle(face_x, face_y, rect.width - 1.5, rect.height - 1.5) - rl.draw_rectangle_rounded(hl_rect, radius, segments, rl.Color(255, 255, 255, 110)) - - surf_rect = rl.Rectangle(face_x + 1.5, face_y + 1.5, rect.width - 3, rect.height - 3) - rl.draw_rectangle_rounded(surf_rect, radius, segments, self.surface_color) - - glare_rect = rl.Rectangle(surf_rect.x, surf_rect.y, surf_rect.width, surf_rect.height * 0.45) - rl.draw_rectangle_rounded(glare_rect, radius, segments, rl.Color(255, 255, 255, 15)) - - return surf_rect + return surface_rect def _draw_text_fit( self, @@ -549,17 +616,23 @@ class AetherTile(Widget): align_right: bool = False, letter_spacing: float = 0, uppercase: bool = False, + color: rl.Color = rl.WHITE, + shadow_alpha: int = 0, ): if uppercase: text = text.upper() - spacing = round(letter_spacing if letter_spacing > 0 else font_size * 0.15) + requested_spacing = letter_spacing if letter_spacing > 0 else font_size * 0.06 + spacing = round(requested_spacing) base_font_size = max(1, int(round(font_size))) size = measure_text_cached(font, text, base_font_size, spacing=spacing) actual_font_size = base_font_size if size.x > max_width: actual_font_size = max(1, int(round(font_size * (max_width / size.x)))) - while actual_font_size > 1 and measure_text_cached(font, text, actual_font_size, spacing=spacing).x > max_width: + fitted_spacing = round(requested_spacing * (actual_font_size / base_font_size)) + while actual_font_size > 1 and measure_text_cached(font, text, actual_font_size, spacing=fitted_spacing).x > max_width: actual_font_size -= 1 + fitted_spacing = round(requested_spacing * (actual_font_size / base_font_size)) + spacing = fitted_spacing render_width = measure_text_cached(font, text, actual_font_size, spacing=spacing).x else: render_width = size.x @@ -569,9 +642,10 @@ class AetherTile(Widget): draw_x = pos.x + (max_width - render_width) / 2 elif align_right: draw_x = pos.x + max_width - render_width - shadow_pos = rl.Vector2(round(draw_x + 1), round(pos.y + nudge_y + 2)) - rl.draw_text_ex(font, text, shadow_pos, actual_font_size, spacing, rl.Color(0, 0, 0, 90)) - rl.draw_text_ex(font, text, rl.Vector2(round(draw_x), round(pos.y + nudge_y)), actual_font_size, spacing, rl.WHITE) + if shadow_alpha > 0: + shadow_pos = rl.Vector2(round(draw_x + 1), round(pos.y + nudge_y + 1)) + rl.draw_text_ex(font, text, shadow_pos, actual_font_size, spacing, rl.Color(0, 0, 0, shadow_alpha)) + rl.draw_text_ex(font, text, rl.Vector2(round(draw_x), round(pos.y + nudge_y)), actual_font_size, spacing, color) def _centered_content( self, face: rl.Rectangle, icon: rl.Texture2D | None, icon_scale: float, title_font_size: float, text_lines: int, line_heights: list[float] @@ -585,9 +659,6 @@ class AetherTile(Widget): group_top = face.y + (face.height - total_h) / 2 if icon: ix = face.x + (face.width - icon_w) / 2 - rl.draw_texture_pro( - icon, rl.Rectangle(0, 0, icon.width, icon.height), rl.Rectangle(ix + 1, group_top + 2, icon_w, icon_h), rl.Vector2(0, 0), 0, rl.Color(0, 0, 0, 90) - ) rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height), rl.Rectangle(ix, group_top, icon_w, icon_h), rl.Vector2(0, 0), 0, rl.WHITE) ty = group_top + icon_h + line_spacing else: @@ -596,7 +667,7 @@ class AetherTile(Widget): def _wrap_text(self, font: rl.Font, text: str, max_width: float, font_size: float, max_lines: int = 2) -> list[str]: spacing = font_size * 0.15 - words = text.upper().split() + words = text.split() lines: list[str] = [] current = "" for word in words: @@ -611,7 +682,134 @@ class AetherTile(Widget): break if current and len(lines) < max_lines: lines.append(current) - return lines if lines else [text.upper()] + return lines if lines else [text] + + def _draw_signal_edge(self, face: rl.Rectangle, color: rl.Color, width: int = 2, alpha: int = 58): + snapped_face = _snap_rect(face) + signal_width = max(1, int(width)) + rl.draw_rectangle_rec(rl.Rectangle(snapped_face.x, snapped_face.y, signal_width, snapped_face.height), _with_alpha(color, alpha)) + + def _measure_tile_stack( + self, + face: rl.Rectangle, + *, + icon_height: float, + title_lines: int, + title_size: int, + primary_size: int, + desc_lines: int, + desc_size: int, + ) -> dict[str, float]: + title_block = title_lines * title_size + max(0, title_lines - 1) * SPACING.line_gap + icon_gap = SPACING.line_gap if icon_height > 0 else 0 + primary_gap = SPACING.line_gap if title_block > 0 else 0 + desc_gap = SPACING.sm if desc_lines > 0 else 0 + desc_block = desc_lines * desc_size + max(0, desc_lines - 1) * SPACING.xs + total_height = icon_height + icon_gap + title_block + primary_gap + primary_size + desc_gap + desc_block + available_height = face.height + if total_height > available_height: + overflow = total_height - available_height + reduce_icon = min(icon_height * 0.35, overflow) + icon_height -= reduce_icon + total_height -= reduce_icon + if total_height > available_height and desc_block > 0: + reduce_desc = min(desc_block * 0.5, total_height - available_height) + desc_block -= reduce_desc + total_height -= reduce_desc + if total_height > available_height: + title_block = max(title_size, title_block - (total_height - available_height)) + total_height = icon_height + icon_gap + title_block + primary_gap + primary_size + desc_gap + desc_block + top = max(face.y, face.y + (available_height - total_height) / 2) + title_y = top + icon_height + icon_gap + primary_y = title_y + title_block + primary_gap + desc_y = primary_y + primary_size + desc_gap + return { + "top": top, + "title_y": title_y, + "primary_y": primary_y, + "desc_y": desc_y, + "desc_bottom": desc_y + desc_block, + } + + def _render_tile_stack( + self, + face: rl.Rectangle, + *, + icon: rl.Texture2D | None, + title: str, + primary: str, + desc: str, + title_font: rl.Font, + primary_font: rl.Font, + desc_font: rl.Font, + title_size: int, + primary_size: int, + desc_size: int = 18, + ): + content_pad = SPACING.tile_content + max_w = face.width - (content_pad * 2) + scale = max(0.82, min(1.12, min(face.width / 360.0, face.height / 205.0))) + title_size = max(22, int(round(title_size * scale))) + primary_size = max(18, int(round(primary_size * scale))) + desc_size = max(14, int(round(desc_size * scale))) + title_lines = self._wrap_text(title_font, title, max_w, title_size, max_lines=2) + icon_scale = min(0.80, max(0.56, scale * 0.72)) if icon else 0.0 + icon_height = (icon.height * icon_scale) if icon else 0.0 + desc_lines = self._wrap_text(desc_font, desc, max_w, desc_size, max_lines=3) if desc else [] + layout = self._measure_tile_stack( + face, + icon_height=icon_height, + title_lines=len(title_lines), + title_size=title_size, + primary_size=primary_size, + desc_lines=len(desc_lines), + desc_size=desc_size, + ) + + if icon: + icon_width = icon.width * icon_scale + icon_x = face.x + (face.width - icon_width) / 2 + rl.draw_texture_pro( + icon, + rl.Rectangle(0, 0, icon.width, icon.height), + rl.Rectangle(icon_x, layout["top"], icon_width, icon_height), + rl.Vector2(0, 0), + 0, + rl.WHITE, + ) + + for i, line in enumerate(title_lines): + self._draw_text_fit( + title_font, + line, + rl.Vector2(face.x + content_pad, layout["title_y"] + i * (title_size + SPACING.line_gap)), + max_w, + title_size, + align_center=True, + color=AetherListColors.HEADER, + ) + + self._draw_text_fit( + primary_font, + primary, + rl.Vector2(face.x + content_pad, layout["primary_y"]), + max_w, + primary_size, + align_center=True, + color=AetherListColors.SUBTEXT if desc else AetherListColors.HEADER, + ) + + if desc_lines: + for i, line in enumerate(desc_lines): + self._draw_text_fit( + desc_font, + line, + rl.Vector2(face.x + content_pad, layout["desc_y"] + i * (desc_size + SPACING.xs)), + max_w, + desc_size, + align_center=True, + color=AetherListColors.MUTED, + ) def _render(self, rect: rl.Rectangle): pass @@ -647,6 +845,7 @@ class HubTile(AetherTile): def _render(self, rect: rl.Rectangle): face = self._render_layers(rect) + self._draw_signal_edge(face, self.surface_color, width=TILE_SIGNAL_WIDTH, alpha=48) status_text = self.get_status() if self.get_status else "" if status_text: @@ -656,25 +855,25 @@ class HubTile(AetherTile): if m: ratio = min(1.0, max(0.0, float(m.group(1)) / 100.0)) if ratio > 0.05: - fill_rect = rl.Rectangle(face.x, face.y, face.width * ratio, face.height) - rl.draw_rectangle_rounded(fill_rect, TILE_RADIUS, 10, rl.Color(255, 255, 255, 40)) - - content_pad = SPACING.tile_content - max_w = face.width - (content_pad * 2) - lines = self._wrap_text(self._font_title, self.title, max_w, 30) - line_heights = [30] * len(lines) - _, ty = self._centered_content(face, self._icon, 0.75, 30, len(line_heights), line_heights) - line_h = 30 - line_spacing = SPACING.line_gap - for i, line in enumerate(lines): - self._draw_text_fit(self._font_title, line, rl.Vector2(face.x + content_pad, ty + i * (line_h + line_spacing)), max_w, line_h, align_center=True) + meter_h = 6 + meter_rect = rl.Rectangle(face.x + SPACING.tile_content, face.y + face.height - SPACING.tile_content - meter_h, face.width - SPACING.tile_content * 2, meter_h) + fill_rect = rl.Rectangle(meter_rect.x, meter_rect.y, meter_rect.width * ratio, meter_h) + rl.draw_rectangle_rec(_snap_rect(meter_rect), rl.Color(255, 255, 255, 14)) + rl.draw_rectangle_rec(_snap_rect(fill_rect), _with_alpha(self.surface_color, 170)) desc_to_render = status_text if status_text else self.desc - if desc_to_render: - desc_lines = self._wrap_text(self._font_desc, desc_to_render, max_w, 18, max_lines=3) - desc_y = ty + len(lines) * (line_h + line_spacing) + SPACING.lg - for i, line in enumerate(desc_lines): - self._draw_text_fit(self._font_desc, line, rl.Vector2(face.x + content_pad, desc_y + i * 20), max_w, 18, align_center=True) + self._render_tile_stack( + face, + icon=self._icon, + title=self.title, + primary=desc_to_render, + desc="", + title_font=self._font_title, + primary_font=self._font_desc, + desc_font=self._font_desc, + title_size=30, + primary_size=18, + ) class ToggleTile(AetherTile): @@ -692,7 +891,7 @@ class ToggleTile(AetherTile): if bg_color: super().__init__(surface_color=bg_color) else: - super().__init__(surface_color=rl.Color(0, 163, 0, 255)) + super().__init__(surface_color=AetherListColors.SUCCESS) self.title = title self.desc = desc self.get_state = get_state @@ -716,26 +915,32 @@ class ToggleTile(AetherTile): def _render(self, rect: rl.Rectangle): enabled = self.enabled active = self.get_state() - if enabled: - self.surface_color = self._active_color if active else self._inactive_color + if enabled and active: + self.surface_color = self._active_color + elif enabled: + self.surface_color = self._inactive_color else: self.surface_color = self._disabled_color self._plate_offset = 0.0 self._plate_target = 0.0 face = self._render_layers(rect) - line_heights = [28, 30] - _, ty = self._centered_content(face, self._icon, 0.75, 28, len(line_heights), line_heights) - content_pad = SPACING.tile_content - max_w = face.width - (content_pad * 2) - self._draw_text_fit(self._font, self.title, rl.Vector2(face.x + content_pad, ty), max_w, 28, align_center=True, uppercase=True) if enabled: state_text = tr("ON") if active else tr("OFF") else: state_text = tr(self._disabled_label) if self._disabled_label else tr("LOCKED") - self._draw_text_fit(self._font, state_text, rl.Vector2(face.x + content_pad, ty + 28 + SPACING.line_gap), max_w, 30, align_center=True, uppercase=True) - - if self.desc: - self._draw_text_fit(self._font_desc, self.desc, rl.Vector2(face.x + content_pad, ty + 28 + SPACING.line_gap + 34), max_w, 18, align_center=True) + self._draw_signal_edge(face, self._active_color if enabled and active else self.surface_color, width=TILE_SIGNAL_WIDTH, alpha=62 if enabled and active else 28) + self._render_tile_stack( + face, + icon=self._icon, + title=self.title, + primary=state_text, + desc=self.desc, + title_font=self._font, + primary_font=self._font, + desc_font=self._font_desc, + title_size=28, + primary_size=30, + ) class ValueTile(AetherTile): @@ -767,16 +972,20 @@ class ValueTile(AetherTile): self._plate_offset = 0.0 self._plate_target = 0.0 face = self._render_layers(rect) - line_heights = [28, 28] - _, ty = self._centered_content(face, self._icon, 0.75, 28, len(line_heights), line_heights) - content_pad = SPACING.tile_content - max_w = face.width - (content_pad * 2) - self._draw_text_fit(self._font, self.title, rl.Vector2(face.x + content_pad, ty), max_w, 28, align_center=True, uppercase=True) val_text = self.get_value() - self._draw_text_fit(self._font, val_text, rl.Vector2(face.x + content_pad, ty + 28 + SPACING.line_gap), max_w, 28, align_center=True, uppercase=True) - - if self.desc: - self._draw_text_fit(self._font_desc, self.desc, rl.Vector2(face.x + content_pad, ty + 28 + SPACING.line_gap + 34), max_w, 18, align_center=True) + self._draw_signal_edge(face, self._active_color if enabled else self._disabled_color, width=TILE_SIGNAL_WIDTH, alpha=38 if enabled else 20) + self._render_tile_stack( + face, + icon=self._icon, + title=self.title, + primary=val_text, + desc=self.desc, + title_font=self._font, + primary_font=self._font, + desc_font=self._font_desc, + title_size=28, + primary_size=28, + ) class SliderTile(AetherTile): @@ -895,33 +1104,31 @@ class SliderTile(AetherTile): self._plate_target = 0.0 face = self._render_layers(rect) + self._draw_signal_edge(face, self.surface_color, width=TILE_SIGNAL_WIDTH, alpha=38 if enabled else 20) - frac = (self._smooth_value - self.min_val) / (self.max_val - self.min_val) - fill_w = face.width * frac + value_range = self.max_val - self.min_val + frac = 0.0 if value_range == 0 else max(0.0, min(1.0, (self._smooth_value - self.min_val) / value_range)) + meter_h = 6 + meter_rect = rl.Rectangle(face.x + SPACING.tile_content, face.y + face.height - SPACING.tile_content - meter_h, face.width - SPACING.tile_content * 2, meter_h) + fill_w = meter_rect.width * frac + rl.draw_rectangle_rec(_snap_rect(meter_rect), rl.Color(255, 255, 255, 14)) if fill_w > 1: - fill_rect = rl.Rectangle(face.x, face.y, fill_w, face.height) - fill_color = rl.Color( - min(self.surface_color.r + 30, 255), - min(self.surface_color.g + 30, 255), - min(self.surface_color.b + 30, 255), - 140, - ) - rl.draw_rectangle_rounded(fill_rect, TILE_RADIUS, 10, fill_color) - edge_x = face.x + fill_w - rl.draw_rectangle_rec(rl.Rectangle(edge_x - 3, face.y, 3, face.height), rl.Color(255, 255, 255, 80)) - - line_heights = [28, 28] - _, ty = self._centered_content(face, self._icon, 0.75, 28, len(line_heights), line_heights) - content_pad = SPACING.tile_content - max_w = face.width - (content_pad * 2) - - self._draw_text_fit(self._font, self.title, rl.Vector2(face.x + content_pad, ty), max_w, 28, align_center=True, uppercase=True) + fill_rect = rl.Rectangle(meter_rect.x, meter_rect.y, fill_w, meter_rect.height) + rl.draw_rectangle_rec(_snap_rect(fill_rect), _with_alpha(self.surface_color, 176)) val_str = self.labels.get(current_val, f"{int(current_val)}{self.unit}") - self._draw_text_fit(self._font, val_str, rl.Vector2(face.x + content_pad, ty + 28 + SPACING.line_gap), max_w, 28, align_center=True, uppercase=True) - - if self.desc: - self._draw_text_fit(self._font_desc, self.desc, rl.Vector2(face.x + content_pad, ty + 28 + SPACING.line_gap + 34), max_w, 18, align_center=True) + self._render_tile_stack( + face, + icon=self._icon, + title=self.title, + primary=val_str, + desc=self.desc, + title_font=self._font, + primary_font=self._font, + desc_font=self._font_desc, + title_size=28, + primary_size=28, + ) class AetherSlider(Widget): @@ -948,13 +1155,24 @@ class AetherSlider(Widget): self._plus_pressed = False def _clamp_and_snap(self, val: float) -> float: + if self.step <= 0: + return max(self.min_val, min(self.max_val, val)) snapped = round((val - self.min_val) / self.step) * self.step + self.min_val return max(self.min_val, min(self.max_val, snapped)) + def _button_width(self, rect: rl.Rectangle) -> int: + return min(SLIDER_BUTTON_SIZE, max(44, int(rect.width * 0.14))) + + def _thumb_size(self, rect: rl.Rectangle, track_h: int | None = None) -> tuple[int, int]: + effective_track_h = track_h if track_h is not None else max(12, int(rect.height * 0.22)) + return max(18, int(effective_track_h * 0.95)), max(34, int(rect.height * 0.50)) + def _get_thumb_x(self, rect: rl.Rectangle) -> float: - track_x = rect.x + SLIDER_BUTTON_SIZE - track_w = rect.width - 2 * SLIDER_BUTTON_SIZE - frac = (self.current_val - self.min_val) / (self.max_val - self.min_val) + button_w = self._button_width(rect) + track_x = rect.x + button_w + track_w = rect.width - 2 * button_w + value_range = self.max_val - self.min_val + frac = 0.0 if value_range == 0 else (self.current_val - self.min_val) / value_range return track_x + frac * track_w def _exponential_ease(self, current: float, target: float, dt: float) -> float: @@ -963,78 +1181,81 @@ class AetherSlider(Widget): return current + (target - current) * (1 - math.exp(-dt / PLATE_TAU)) def _draw_slider_button(self, rect: rl.Rectangle, label: str): - shadow_alpha = int(255 * (1.0 - 0.8 * self._minus_offset if label == "-" else 1.0 - 0.8 * self._plus_offset)) offset = self._minus_offset if label == "-" else self._plus_offset - substrate = rl.Color(100, 100, 100, shadow_alpha) - rl.draw_rectangle_rounded(rl.Rectangle(rect.x + GEOMETRY_OFFSET, rect.y + GEOMETRY_OFFSET, rect.width, rect.height), 0.2, 10, substrate) - face_x = rect.x + GEOMETRY_OFFSET * offset - face_y = rect.y + GEOMETRY_OFFSET * offset - face_rect = rl.Rectangle(face_x, face_y, rect.width, rect.height) - btn_color = rl.Color(80, 80, 80, 255) - rl.draw_rectangle_rounded(face_rect, 0.2, 10, btn_color) - rl.draw_rectangle_rounded(rl.Rectangle(face_rect.x + 1, face_rect.y + 1, face_rect.width - 2, face_rect.height - 2), 0.2, 10, rl.Color(0, 0, 0, 80)) - rl.draw_rectangle_rounded(rl.Rectangle(face_rect.x, face_rect.y, face_rect.width - 1.5, face_rect.height - 1.5), 0.2, 10, rl.Color(255, 255, 255, 110)) - ts = measure_text_cached(self._font, label, 35) + face_x = _snap(rect.x) + face_y = _snap(rect.y + min(1.0, offset)) + face_rect = _snap_rect(rl.Rectangle(face_x, face_y, rect.width, rect.height)) + btn_color = rl.Color(34, 38, 48, 255) + border_color = rl.Color(255, 255, 255, 28) + _draw_rounded_fill(face_rect, btn_color, radius_px=16) + _draw_rounded_stroke(face_rect, border_color, radius_px=16) + rl.draw_rectangle_rec(rl.Rectangle(face_rect.x, face_rect.y, face_rect.width, 1), rl.Color(255, 255, 255, 16)) + font_size = max(22, int(round(min(rect.width, rect.height) * 0.52))) + ts = measure_text_cached(self._font, label, font_size) label_pos = rl.Vector2(face_x + (rect.width - ts.x) / 2, face_y + (rect.height - ts.y) / 2) - rl.draw_text_ex(self._font, label, rl.Vector2(round(label_pos.x + 2), round(label_pos.y + 2)), 35, 0, rl.Color(0, 0, 0, 70)) - rl.draw_text_ex(self._font, label, rl.Vector2(round(label_pos.x), round(label_pos.y)), 35, 0, rl.WHITE) + rl.draw_text_ex(self._font, label, rl.Vector2(round(label_pos.x), round(label_pos.y)), font_size, 0, rl.WHITE) def _render(self, rect: rl.Rectangle): + rect = _snap_rect(rect) self.set_rect(rect) dt = rl.get_frame_time() if self._is_dragging: self._update_val_from_mouse(rl.get_mouse_position()) self._minus_offset = self._exponential_ease(self._minus_offset, 1.0 if self._minus_pressed else 0.0, dt) self._plus_offset = self._exponential_ease(self._plus_offset, 1.0 if self._plus_pressed else 0.0, dt) - minus_rect = rl.Rectangle(rect.x, rect.y, SLIDER_BUTTON_SIZE, rect.height) - plus_rect = rl.Rectangle(rect.x + rect.width - SLIDER_BUTTON_SIZE, rect.y, SLIDER_BUTTON_SIZE, rect.height) + button_w = self._button_width(rect) + minus_rect = _snap_rect(rl.Rectangle(rect.x, rect.y, button_w, rect.height)) + plus_rect = _snap_rect(rl.Rectangle(rect.x + rect.width - button_w, rect.y, button_w, rect.height)) self._draw_slider_button(minus_rect, "-") self._draw_slider_button(plus_rect, "+") - track_x = rect.x + SLIDER_BUTTON_SIZE - track_w = rect.width - 2 * SLIDER_BUTTON_SIZE - track_h = 20 - track_rect = rl.Rectangle(track_x, rect.y + (rect.height - track_h) / 2, track_w, track_h) - rl.draw_rectangle_rounded(track_rect, 1.0, 10, rl.Color(50, 50, 50, 255)) - frac = (self.current_val - self.min_val) / (self.max_val - self.min_val) + track_x = rect.x + button_w + track_w = rect.width - 2 * button_w + track_h = max(12, int(rect.height * 0.22)) + track_rect = _snap_rect(rl.Rectangle(track_x, rect.y + (rect.height - track_h) / 2, track_w, track_h)) + _draw_rounded_fill(track_rect, rl.Color(34, 38, 48, 255), radius_px=track_h / 2) + _draw_rounded_stroke(track_rect, rl.Color(255, 255, 255, 20), radius_px=track_h / 2) + value_range = self.max_val - self.min_val + frac = 0.0 if value_range == 0 else (self.current_val - self.min_val) / value_range fill_w = frac * track_w if fill_w > 0: - rl.draw_rectangle_rounded(rl.Rectangle(track_x, track_rect.y, fill_w, track_h), 1.0, 10, self.color) - n_steps = int(round((self.max_val - self.min_val) / self.step)) - for i in range(n_steps + 1): - tick_x = track_x + (i / n_steps) * track_w - tick_h = int(track_h * 0.6) - tick_y = track_rect.y + (track_h - tick_h) / 2 - rl.draw_rectangle_rec(rl.Rectangle(tick_x - 1, tick_y, 2, tick_h), rl.Color(255, 255, 255, 60)) - thumb_w, thumb_h = 24, 44 + fill_rect = _snap_rect(rl.Rectangle(track_x, track_rect.y, fill_w, track_h)) + rl.draw_rectangle_rec(fill_rect, _with_alpha(self.color, 190)) + if self.step > 0: + n_steps = int(round(value_range / self.step)) + if n_steps > 0: + for i in range(n_steps + 1): + tick_x = track_x + (i / n_steps) * track_w + tick_h = int(track_h * 0.6) + tick_y = track_rect.y + (track_h - tick_h) / 2 + rl.draw_rectangle_rec(rl.Rectangle(tick_x - 1, tick_y, 2, tick_h), rl.Color(255, 255, 255, 60)) + thumb_w, thumb_h = self._thumb_size(rect, track_h) thumb_x = self._get_thumb_x(rect) - thumb_w / 2 thumb_y = rect.y + (rect.height - thumb_h) / 2 thumb_offset = GEOMETRY_OFFSET * self._thumb_offset - t_substrate = rl.Color(180, 180, 180, int(255 * (1.0 - 0.8 * self._thumb_offset))) - rl.draw_rectangle_rounded(rl.Rectangle(thumb_x + GEOMETRY_OFFSET, thumb_y + GEOMETRY_OFFSET, thumb_w, thumb_h), 0.2, 10, t_substrate) - t_face_rect = rl.Rectangle(thumb_x + thumb_offset, thumb_y + thumb_offset, thumb_w, thumb_h) - rl.draw_rectangle_rounded(t_face_rect, 0.2, 10, rl.WHITE) - rl.draw_rectangle_rounded(rl.Rectangle(t_face_rect.x + 1, t_face_rect.y + 1, t_face_rect.width - 2, t_face_rect.height - 2), 0.2, 10, rl.Color(0, 0, 0, 80)) - rl.draw_rectangle_rounded( - rl.Rectangle(t_face_rect.x, t_face_rect.y, t_face_rect.width - 1.5, t_face_rect.height - 1.5), 0.2, 10, rl.Color(255, 255, 255, 110) - ) + t_face_rect = _snap_rect(rl.Rectangle(thumb_x, thumb_y + min(1.0, thumb_offset), thumb_w, thumb_h)) + _draw_rounded_fill(t_face_rect, rl.Color(230, 235, 242, 255), radius_px=12) + _draw_rounded_stroke(t_face_rect, rl.Color(20, 22, 28, 46), radius_px=12) + rl.draw_rectangle_rec(rl.Rectangle(t_face_rect.x, t_face_rect.y, t_face_rect.width, 1), rl.Color(255, 255, 255, 40)) val_str = self.labels.get(self.current_val, f"{self.current_val:.2f}".rstrip('0').rstrip('.') + self.unit) - ts = measure_text_cached(self._font, val_str, 35) - val_pos = rl.Vector2(thumb_x + (thumb_w - ts.x) / 2, thumb_y - 45) - rl.draw_text_ex(self._font, val_str, rl.Vector2(round(val_pos.x + 2), round(val_pos.y + 2)), 35, 0, rl.Color(0, 0, 0, 70)) - rl.draw_text_ex(self._font, val_str, rl.Vector2(round(val_pos.x), round(val_pos.y)), 35, 0, rl.WHITE) + label_size = max(18, int(round(rect.height * 0.38))) + ts = measure_text_cached(self._font, val_str, label_size) + val_x = max(rect.x, min(thumb_x + (thumb_w - ts.x) / 2, rect.x + rect.width - ts.x)) + val_pos = rl.Vector2(val_x, thumb_y - label_size - 10) + rl.draw_text_ex(self._font, val_str, rl.Vector2(round(val_pos.x), round(val_pos.y)), label_size, 0, rl.WHITE) def _handle_mouse_press(self, mouse_pos: MousePos): if not rl.check_collision_point_rec(mouse_pos, self._rect): return - minus_rect = rl.Rectangle(self._rect.x, self._rect.y, SLIDER_BUTTON_SIZE, self._rect.height) - plus_rect = rl.Rectangle(self._rect.x + self._rect.width - SLIDER_BUTTON_SIZE, self._rect.y, SLIDER_BUTTON_SIZE, self._rect.height) + button_w = self._button_width(self._rect) + minus_rect = rl.Rectangle(self._rect.x, self._rect.y, button_w, self._rect.height) + plus_rect = rl.Rectangle(self._rect.x + self._rect.width - button_w, self._rect.y, button_w, self._rect.height) if rl.check_collision_point_rec(mouse_pos, minus_rect): self._minus_pressed = True return if rl.check_collision_point_rec(mouse_pos, plus_rect): self._plus_pressed = True return - thumb_w, thumb_h = 24, 44 + thumb_w, thumb_h = self._thumb_size(self._rect) thumb_x = self._get_thumb_x(self._rect) - thumb_w / 2 thumb_y = self._rect.y + (self._rect.height - thumb_h) / 2 thumb_rect = rl.Rectangle(thumb_x - 8, thumb_y - 8, thumb_w + 16, thumb_h + 16) @@ -1042,8 +1263,8 @@ class AetherSlider(Widget): self._is_dragging = True self._thumb_offset = 1.0 else: - track_x = self._rect.x + SLIDER_BUTTON_SIZE - track_w = self._rect.width - 2 * SLIDER_BUTTON_SIZE + track_x = self._rect.x + button_w + track_w = self._rect.width - 2 * button_w if track_w > 0: rel_x = max(0.0, min(1.0, (mouse_pos.x - track_x) / track_w)) val = self.min_val + rel_x * (self.max_val - self.min_val) @@ -1053,18 +1274,17 @@ class AetherSlider(Widget): self.on_change(self.current_val) def _handle_mouse_release(self, mouse_pos: MousePos): + button_w = self._button_width(self._rect) if self._minus_pressed: self._minus_pressed = False - if rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x, self._rect.y, SLIDER_BUTTON_SIZE, self._rect.height)): + if rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x, self._rect.y, button_w, self._rect.height)): new_val = self._clamp_and_snap(self.current_val - self.step) if new_val != self.current_val: self.current_val = new_val self.on_change(self.current_val) if self._plus_pressed: self._plus_pressed = False - if rl.check_collision_point_rec( - mouse_pos, rl.Rectangle(self._rect.x + self._rect.width - SLIDER_BUTTON_SIZE, self._rect.y, SLIDER_BUTTON_SIZE, self._rect.height) - ): + if rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x + self._rect.width - button_w, self._rect.y, button_w, self._rect.height)): new_val = self._clamp_and_snap(self.current_val + self.step) if new_val != self.current_val: self.current_val = new_val @@ -1074,8 +1294,9 @@ class AetherSlider(Widget): self._thumb_offset = 0.0 def _update_val_from_mouse(self, mouse_pos: MousePos): - track_x = self._rect.x + SLIDER_BUTTON_SIZE - track_w = self._rect.width - 2 * SLIDER_BUTTON_SIZE + button_w = self._button_width(self._rect) + track_x = self._rect.x + button_w + track_w = self._rect.width - 2 * button_w if track_w <= 0: return rel_x = max(0.0, min(1.0, (mouse_pos.x - track_x) / track_w)) @@ -1142,54 +1363,44 @@ class AetherSliderDialog(Widget): 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, dialog_h = 1000, 500 dialog_margin = SPACING.xxl - button_height = 80 - button_width = 350 + dialog_w = min(1000, max(640, rect.width - dialog_margin * 2)) + dialog_h = min(500, max(360, rect.height - dialog_margin * 2)) + button_height = min(80, max(64, int(dialog_h * 0.16))) + button_width = min(350, max(180, int((dialog_w - SPACING.lg * 3) / 2))) 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 - SPACING.lg, dy + dialog_h - button_height - SPACING.lg, button_width, button_height) self._cancel_rect = rl.Rectangle(dx + SPACING.lg, dy + dialog_h - button_height - SPACING.lg, button_width, button_height) - d_rect = rl.Rectangle(dx, dy, dialog_w, dialog_h) - rl.draw_rectangle_rounded(d_rect, 0.05, 10, rl.Color(30, 30, 30, 255)) - rl.draw_rectangle_rounded_lines_ex(d_rect, 0.05, 10, 2, self._color) - ts = measure_text_cached(self._font_title, self.title, 50) - rl.draw_text_ex(self._font_title, self.title, rl.Vector2(round(dx + (dialog_w - ts.x) / 2), round(dy + SPACING.xxl)), 50, 0, rl.WHITE) - slider_rect = rl.Rectangle(dx + SPACING.xxl, dy + 200, dialog_w - (SPACING.xxl * 2), 100) + d_rect = _snap_rect(rl.Rectangle(dx, dy, dialog_w, dialog_h)) + _draw_rounded_fill(d_rect, rl.Color(13, 16, 22, 255), radius_px=24) + _draw_rounded_stroke(d_rect, rl.Color(255, 255, 255, 24), radius_px=24) + rl.draw_rectangle_rec(rl.Rectangle(d_rect.x, d_rect.y, d_rect.width, 2), _with_alpha(self._color, 40)) + title_size = max(30, min(50, int(dialog_w * 0.05))) + 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 + SPACING.xxl)), title_size, 0, rl.WHITE) + slider_y = dy + max(120, int(dialog_h * 0.38)) + slider_h = min(100, max(72, int(dialog_h * 0.22))) + slider_rect = rl.Rectangle(dx + SPACING.xxl, slider_y, dialog_w - (SPACING.xxl * 2), slider_h) self._slider.render(slider_rect) - c_shadow_alpha = int(255 * (1.0 - 0.9 * self._cancel_offset)) - rl.draw_rectangle_rounded( - rl.Rectangle(self._cancel_rect.x + GEOMETRY_OFFSET, self._cancel_rect.y + GEOMETRY_OFFSET, button_width, button_height), - 0.2, - 10, - rl.Color(30, 30, 30, c_shadow_alpha), - ) - c_face_x = self._cancel_rect.x + GEOMETRY_OFFSET * self._cancel_offset - c_face_y = self._cancel_rect.y + GEOMETRY_OFFSET * self._cancel_offset - c_face = rl.Rectangle(c_face_x, c_face_y, button_width, button_height) - rl.draw_rectangle_rounded(c_face, 0.2, 10, rl.Color(80, 80, 80, 255)) - rl.draw_rectangle_rounded(rl.Rectangle(c_face.x + 1, c_face.y + 1, c_face.width - 2, c_face.height - 2), 0.2, 10, rl.Color(0, 0, 0, 80)) - rl.draw_rectangle_rounded(rl.Rectangle(c_face.x, c_face.y, c_face.width - 1.5, c_face.height - 1.5), 0.2, 10, rl.Color(255, 255, 255, 110)) - cts = measure_text_cached(self._font_btn, tr("CANCEL"), 35) + c_face_x = self._cancel_rect.x + c_face_y = self._cancel_rect.y + min(1.0, GEOMETRY_OFFSET * self._cancel_offset * 0.1) + c_face = _snap_rect(rl.Rectangle(c_face_x, c_face_y, button_width, button_height)) + _draw_rounded_fill(c_face, rl.Color(34, 38, 48, 255), radius_px=16) + _draw_rounded_stroke(c_face, rl.Color(255, 255, 255, 24), radius_px=16) + rl.draw_rectangle_rec(rl.Rectangle(c_face.x, c_face.y, c_face.width, 1), rl.Color(255, 255, 255, 12)) + button_text_size = max(24, min(35, int(button_height * 0.42))) + cts = measure_text_cached(self._font_btn, tr("CANCEL"), button_text_size) cancel_text_pos = rl.Vector2(c_face_x + (button_width - cts.x) / 2, c_face_y + (button_height - cts.y) / 2) - rl.draw_text_ex(self._font_btn, tr("CANCEL"), rl.Vector2(round(cancel_text_pos.x + 1), round(cancel_text_pos.y + 2)), 35, 0, rl.Color(0, 0, 0, 90)) - rl.draw_text_ex(self._font_btn, tr("CANCEL"), rl.Vector2(round(cancel_text_pos.x), round(cancel_text_pos.y)), 35, 0, rl.WHITE) - o_shadow_alpha = int(255 * (1.0 - 0.9 * self._ok_offset)) - rl.draw_rectangle_rounded( - rl.Rectangle(self._ok_rect.x + GEOMETRY_OFFSET, self._ok_rect.y + GEOMETRY_OFFSET, button_width, button_height), - 0.2, - 10, - rl.Color(self._color.r, self._color.g, self._color.b, int(o_shadow_alpha * 0.4)), - ) - o_face_x = self._ok_rect.x + GEOMETRY_OFFSET * self._ok_offset - o_face_y = self._ok_rect.y + GEOMETRY_OFFSET * self._ok_offset - o_face = rl.Rectangle(o_face_x, o_face_y, button_width, button_height) - rl.draw_rectangle_rounded(o_face, 0.2, 10, self._color) - rl.draw_rectangle_rounded(rl.Rectangle(o_face.x + 1, o_face.y + 1, o_face.width - 2, o_face.height - 2), 0.2, 10, rl.Color(0, 0, 0, 80)) - rl.draw_rectangle_rounded(rl.Rectangle(o_face.x, o_face.y, o_face.width - 1.5, o_face.height - 1.5), 0.2, 10, rl.Color(255, 255, 255, 110)) - ots = measure_text_cached(self._font_btn, tr("OK"), 35) + rl.draw_text_ex(self._font_btn, tr("CANCEL"), rl.Vector2(round(cancel_text_pos.x), round(cancel_text_pos.y)), button_text_size, 0, rl.WHITE) + o_face_x = self._ok_rect.x + o_face_y = self._ok_rect.y + min(1.0, GEOMETRY_OFFSET * self._ok_offset * 0.1) + o_face = _snap_rect(rl.Rectangle(o_face_x, o_face_y, button_width, button_height)) + _draw_rounded_fill(o_face, _mix_colors(rl.Color(34, 38, 48, 255), self._color, 0.40), radius_px=16) + _draw_rounded_stroke(o_face, _with_alpha(self._color, 130), radius_px=16) + rl.draw_rectangle_rec(rl.Rectangle(o_face.x, o_face.y, o_face.width, 1), rl.Color(255, 255, 255, 18)) + ots = measure_text_cached(self._font_btn, tr("OK"), button_text_size) ok_text_pos = rl.Vector2(o_face_x + (button_width - ots.x) / 2, o_face_y + (button_height - ots.y) / 2) - rl.draw_text_ex(self._font_btn, tr("OK"), rl.Vector2(round(ok_text_pos.x + 1), round(ok_text_pos.y + 2)), 35, 0, rl.Color(0, 0, 0, 90)) - rl.draw_text_ex(self._font_btn, tr("OK"), rl.Vector2(round(ok_text_pos.x), round(ok_text_pos.y)), 35, 0, rl.WHITE) + rl.draw_text_ex(self._font_btn, tr("OK"), rl.Vector2(round(ok_text_pos.x), round(ok_text_pos.y)), button_text_size, 0, rl.WHITE) return DialogResult.NO_ACTION @@ -1198,7 +1409,7 @@ class RadioTileGroup(Widget): super().__init__() self.title, self.options, self.current_index, self.on_change = title, options, current_index, on_change self._font, self._font_title = gui_app.font(FontWeight.BOLD), gui_app.font(FontWeight.NORMAL) - self._active_color, self._inactive_color = rl.Color(54, 77, 239, 255), rl.Color(80, 80, 80, 255) + self._active_color, self._inactive_color = AetherListColors.PRIMARY, rl.Color(80, 80, 80, 255) self._pressed_index = -1 self._option_rects: list[rl.Rectangle] = [] self._option_offsets: list[float] = [] @@ -1229,6 +1440,7 @@ class RadioTileGroup(Widget): self._pressed_index = -1 def _render(self, rect: rl.Rectangle): + rect = _snap_rect(rect) self.set_rect(rect) self._option_rects.clear() dt = rl.get_frame_time() @@ -1247,36 +1459,28 @@ class RadioTileGroup(Widget): else: start_x = rect.x + (rect.width - total_width) / 2 for i, opt in enumerate(self.options): - r = rl.Rectangle(start_x + i * (option_w + gap), rect.y, option_w, rect.height) + r = _snap_rect(rl.Rectangle(start_x + i * (option_w + gap), rect.y, option_w, rect.height)) self._option_rects.append(r) is_active = i == self.current_index - color = self._active_color if is_active else self._inactive_color + fill = _mix_colors(rl.Color(28, 32, 40, 255), self._active_color, 0.18 if is_active else 0.05) + border = _with_alpha(self._active_color if is_active else rl.Color(255, 255, 255, 36), 96 if is_active else 28) offset = self._option_offsets[i] if i < len(self._option_offsets) else 0.0 - extrusion_alpha = int(255 * (1.0 - 0.9 * offset)) - rl.draw_rectangle_rounded( - rl.Rectangle(r.x + GEOMETRY_OFFSET, r.y + GEOMETRY_OFFSET, r.width, r.height), TILE_RADIUS, 10, rl.Color(30, 30, 30, extrusion_alpha) - ) - face_x = r.x + GEOMETRY_OFFSET * offset - face_y = r.y + GEOMETRY_OFFSET * offset - face_rect = rl.Rectangle(face_x, face_y, r.width, r.height) - rl.draw_rectangle_rounded(face_rect, TILE_RADIUS, 10, color) - rl.draw_rectangle_rounded( - rl.Rectangle(face_rect.x + 1, face_rect.y + 1, face_rect.width - 2, face_rect.height - 2), TILE_RADIUS, 10, rl.Color(0, 0, 0, 80) - ) - rl.draw_rectangle_rounded( - rl.Rectangle(face_rect.x, face_rect.y, face_rect.width - 1.5, face_rect.height - 1.5), TILE_RADIUS, 10, rl.Color(255, 255, 255, 110) - ) - font_size = 30 - spacing = round(font_size * 0.08) + face_x = r.x + face_y = r.y + min(1.0, offset) + face_rect = _snap_rect(rl.Rectangle(face_x, face_y, r.width, r.height)) + _draw_rounded_fill(face_rect, fill, radius_px=16) + _draw_rounded_stroke(face_rect, border, radius_px=16) + rl.draw_rectangle_rec(rl.Rectangle(face_rect.x, face_rect.y, face_rect.width, 1), rl.Color(255, 255, 255, 16)) + font_size = max(18, min(30, int(r.height * 0.34))) + spacing = round(font_size * 0.05) max_width = r.width - (SPACING.lg + SPACING.xs) ts = measure_text_cached(self._font, opt, font_size, spacing=spacing) - while font_size > 22 and ts.x > max_width: + while font_size > 16 and ts.x > max_width: font_size -= 1 - spacing = round(font_size * 0.08) + spacing = round(font_size * 0.05) ts = measure_text_cached(self._font, opt, font_size, spacing=spacing) text_pos = rl.Vector2(face_x + (r.width - ts.x) / 2, face_y + (r.height - ts.y) / 2) - rl.draw_text_ex(self._font, opt, rl.Vector2(round(text_pos.x + 1), round(text_pos.y + 2)), font_size, spacing, rl.Color(0, 0, 0, 90)) - rl.draw_text_ex(self._font, opt, rl.Vector2(round(text_pos.x), round(text_pos.y)), font_size, spacing, rl.WHITE) + 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 TileGrid(Widget): @@ -1315,25 +1519,34 @@ class TileGrid(Widget): return 3 return 4 - def get_row_count(self, tile_count: int | None = None) -> int: + def get_effective_column_count(self, available_width: float | None = None, tile_count: int | None = None) -> int: + count = len(self.tiles) if tile_count is None else tile_count + preferred = self.get_column_count(count) + if available_width is None or available_width <= 0: + return preferred + max_cols_by_width = max(1, int((available_width + self._gap) / (MIN_TILE_WIDTH + self._gap))) + return max(1, min(preferred, count, max_cols_by_width)) + + def get_row_count(self, tile_count: int | None = None, available_width: float | None = None) -> int: count = len(self.tiles) if tile_count is None else tile_count if count <= 0: return 0 - cols = self.get_column_count(count) + cols = self.get_effective_column_count(available_width, count) return (count + cols - 1) // cols - def get_internal_gap_height(self, tile_count: int | None = None) -> float: - rows = self.get_row_count(tile_count) + def get_internal_gap_height(self, tile_count: int | None = None, available_width: float | None = None) -> float: + rows = self.get_row_count(tile_count, available_width=available_width) return self._gap * max(0, rows - 1) def _render(self, rect: rl.Rectangle): + rect = _snap_rect(rect) self.set_rect(rect) if not self.tiles: return tiles_to_render = list(self.tiles) count = len(tiles_to_render) - cols = self.get_column_count(count) - rows = self.get_row_count(count) + cols = self.get_effective_column_count(rect.width, count) + rows = self.get_row_count(count, available_width=rect.width) tile_h = (rect.height - (self._gap * (rows - 1))) / rows uniform_tile_w = (rect.width - (self._gap * (cols - 1))) / cols if self._uniform_width else 0 tile_idx = 0 @@ -1351,7 +1564,10 @@ class TileGrid(Widget): row_x = rect.x for c in range(items_in_row): tile = tiles_to_render[tile_idx] - tile.render(rl.Rectangle(row_x + c * (row_tile_w + self._gap), rect.y + r * (tile_h + self._gap), row_tile_w, tile_h)) + 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 + c * (row_tile_w + self._gap), rect.y + r * (tile_h + self._gap), row_tile_w, tile_h))) tile_idx += 1 class AetherContinuousSlider(Widget): @@ -1407,6 +1623,12 @@ class AetherContinuousSlider(Widget): self._set_snapped_val(val, step) def _set_snapped_val(self, val: float, step: float): + if step <= 0: + snapped = max(self.min_val, min(self.max_val, val)) + if snapped != self.current_val: + self.current_val = snapped + self.on_change(self.current_val) + return snapped = round((val - self.min_val) / step) * step + self.min_val snapped = max(self.min_val, min(self.max_val, snapped)) if snapped != self.current_val: @@ -1414,59 +1636,62 @@ class AetherContinuousSlider(Widget): self.on_change(self.current_val) def _render(self, rect: rl.Rectangle): + rect = _snap_rect(rect) self.set_rect(rect) dt = rl.get_frame_time() self._smooth_value += (self.current_val - self._smooth_value) * (1 - math.exp(-dt / 0.060)) - rl.draw_rectangle_rounded(rect, 0.3, 16, rl.Color(35, 35, 40, 255)) + _draw_rounded_fill(rect, rl.Color(28, 32, 40, 255), radius_px=18) + _draw_rounded_stroke(rect, rl.Color(255, 255, 255, 18), radius_px=18) - frac = max(0.0, min(1.0, (self._smooth_value - self.min_val) / (self.max_val - self.min_val))) - fill_w = frac * rect.width + value_range = self.max_val - self.min_val + frac = 0.0 if value_range == 0 else max(0.0, min(1.0, (self._smooth_value - self.min_val) / value_range)) + meter_h = max(4, int(rect.height * 0.12)) + meter_rect = _snap_rect(rl.Rectangle(rect.x + 20, rect.y + rect.height - meter_h - 10, rect.width - 40, meter_h)) + fill_w = frac * meter_rect.width + rl.draw_rectangle_rec(meter_rect, rl.Color(255, 255, 255, 14)) if fill_w > 0: - fill_rect = rl.Rectangle(rect.x, rect.y, fill_w, rect.height) - rl.draw_rectangle_rounded(fill_rect, 0.3, 16, self.color) + fill_rect = _snap_rect(rl.Rectangle(meter_rect.x, meter_rect.y, fill_w, meter_rect.height)) + rl.draw_rectangle_rec(fill_rect, _with_alpha(self.color, 190)) - if fill_w > 16: - rl.draw_rectangle_rounded(rl.Rectangle(fill_rect.x, fill_rect.y, fill_rect.width - 2, fill_rect.height - 2), 0.3, 16, rl.Color(255, 255, 255, 30)) - - title_y = rect.y + (rect.height - 24) / 2 - rl.draw_text_ex(self._font, self.title, rl.Vector2(round(rect.x + 24), round(title_y)), 24, 0, rl.WHITE) + title_size = max(18, min(24, int(rect.height * 0.34))) + value_size = max(18, min(24, int(rect.height * 0.34))) + title_y = rect.y + (rect.height - title_size) / 2 - 5 + rl.draw_text_ex(self._font, self.title, rl.Vector2(round(rect.x + 24), round(title_y)), title_size, 0, AetherListColors.SUBTEXT) val_str = self.labels.get(self.current_val, f"{int(self.current_val)}{self.unit}") - ts = measure_text_cached(self._font, val_str, 24) + ts = measure_text_cached(self._font, val_str, value_size) - text_color = rl.WHITE if frac < 0.85 else rl.Color(0, 0, 0, 180) + text_color = AetherListColors.HEADER text_x = rect.x + rect.width - ts.x - 24 - text_y = rect.y + (rect.height - ts.y) / 2 - rl.draw_text_ex(self._font, val_str, rl.Vector2(round(text_x), round(text_y)), 24, 0, text_color) + text_y = rect.y + (rect.height - ts.y) / 2 - 5 + rl.draw_text_ex(self._font, val_str, rl.Vector2(round(text_x), round(text_y)), value_size, 0, text_color) def draw_toggle_pill(rect: rl.Rectangle, is_on: bool, is_enabled: bool, title: str, status_str: str, hovered: bool, pressed: bool): - if not is_enabled: - bg_color = rl.Color(35, 35, 40, 150) - elif is_on: - bg_color = AetherListColors.PRIMARY - else: - bg_color = rl.Color(35, 35, 40, 255) - - rl.draw_rectangle_rounded(rect, 0.3, 16, bg_color) - + rect = _snap_rect(rect) + bg_color = rl.Color(28, 32, 40, 170 if not is_enabled else 255) + accent = AetherListColors.PRIMARY if is_on and is_enabled else rl.Color(255, 255, 255, 52 if is_enabled else 20) + _draw_rounded_fill(rect, bg_color, radius_px=18) + _draw_rounded_stroke(rect, _with_alpha(accent, 92 if is_on and is_enabled else accent.a), radius_px=18) + if (hovered or pressed) and is_enabled: overlay = rl.Color(255, 255, 255, 14 if pressed else 8) - rl.draw_rectangle_rounded(rect, 0.3, 16, overlay) - + _draw_rounded_fill(rect, overlay, radius_px=18) + if is_on and is_enabled: - rl.draw_rectangle_rounded(rl.Rectangle(rect.x, rect.y, rect.width - 2, rect.height - 2), 0.3, 16, rl.Color(255, 255, 255, 30)) - + rl.draw_rectangle_rec(rl.Rectangle(rect.x, rect.y, 1, rect.height), _with_alpha(AetherListColors.PRIMARY, 160)) + font = gui_app.font(FontWeight.BOLD) - title_y = rect.y + (rect.height - 24) / 2 - text_color = rl.WHITE if is_enabled else AetherListColors.MUTED - rl.draw_text_ex(font, title, rl.Vector2(round(rect.x + 24), round(title_y)), 24, 0, text_color) + title_size = max(16, min(22, int(rect.height * 0.26))) + status_size = max(18, min(24, int(rect.height * 0.32))) + title_y = rect.y + (rect.height - title_size) / 2 + rl.draw_text_ex(font, title, rl.Vector2(round(rect.x + 24), round(title_y)), title_size, 0, AetherListColors.SUBTEXT if is_enabled else AetherListColors.MUTED) - ts = measure_text_cached(font, status_str, 24) + ts = measure_text_cached(font, status_str, status_size) status_x = rect.x + rect.width - ts.x - 24 - rl.draw_text_ex(font, status_str, rl.Vector2(round(status_x), round(title_y)), 24, 0, text_color) + rl.draw_text_ex(font, status_str, rl.Vector2(round(status_x), round(rect.y + (rect.height - ts.y) / 2)), status_size, 0, AetherListColors.HEADER if is_enabled else AetherListColors.MUTED) class AetherVerticalSlider(Widget): @@ -1535,57 +1760,56 @@ class AetherVerticalSlider(Widget): # Inverted: top = max, bottom = min frac = 1.0 - max(0.0, min(1.0, (mouse_y - tr_rect.y) / tr_rect.height)) val = self.min_val + frac * (self.max_val - self.min_val) - snapped = round((val - self.min_val) / step) * step + self.min_val + if step <= 0: + snapped = max(self.min_val, min(self.max_val, val)) + else: + snapped = round((val - self.min_val) / step) * step + self.min_val snapped = max(self.min_val, min(self.max_val, snapped)) if snapped != self.current_val: self.current_val = snapped self.on_change(self.current_val) def _render(self, rect: rl.Rectangle): + rect = _snap_rect(rect) self.set_rect(rect) dt = rl.get_frame_time() self._smooth_value += (self.current_val - self._smooth_value) * (1 - math.exp(-dt / 0.060)) - # Layout: title (20px) + gap(6) + track + gap(6) + value (20px) - title_h = 20 - value_h = 20 - gap = 6 + title_h = max(16, min(20, int(rect.height * 0.12))) + value_h = max(16, min(20, int(rect.height * 0.12))) + gap = max(4, min(6, int(rect.height * 0.03))) track_top = rect.y + title_h + gap track_h = rect.height - title_h - gap - value_h - gap - track_w = max(self.MIN_TRACK_WIDTH, min(rect.width * 0.7, rect.width - 16)) + available_track_w = max(24.0, rect.width - 16) + track_w = min(available_track_w, max(36.0, min(rect.width * 0.7, float(self.MIN_TRACK_WIDTH)))) track_x = rect.x + (rect.width - track_w) / 2 - self._track_rect = rl.Rectangle(track_x, track_top, track_w, track_h) + self._track_rect = _snap_rect(rl.Rectangle(track_x, track_top, track_w, track_h)) - # Title (centered above track) - ts = measure_text_cached(self._font, self.title, 18) + title_size = max(14, min(18, int(rect.width * 0.16))) + value_size = max(14, min(18, int(rect.width * 0.16))) + ts = measure_text_cached(self._font, self.title, title_size) tx = rect.x + (rect.width - ts.x) / 2 - rl.draw_text_ex(self._font, self.title, rl.Vector2(round(tx), round(rect.y)), 18, 0, AetherListColors.SUBTEXT) + rl.draw_text_ex(self._font, self.title, rl.Vector2(round(tx), round(rect.y)), title_size, 0, AetherListColors.SUBTEXT) - # Track background - rl.draw_rectangle_rounded(self._track_rect, 0.3, 10, rl.Color(40, 42, 50, 255)) + _draw_rounded_fill(self._track_rect, rl.Color(34, 38, 48, 255), radius_px=16) + _draw_rounded_stroke(self._track_rect, rl.Color(255, 255, 255, 18), radius_px=16) - # Fill (from bottom up) - frac = max(0.0, min(1.0, (self._smooth_value - self.min_val) / (self.max_val - self.min_val))) + value_range = self.max_val - self.min_val + frac = 0.0 if value_range == 0 else max(0.0, min(1.0, (self._smooth_value - self.min_val) / value_range)) fill_h = frac * track_h if fill_h > 1: - fill_rect = rl.Rectangle(track_x, track_top + track_h - fill_h, track_w, fill_h) - rl.draw_rectangle_rounded(fill_rect, 0.3, 10, self.color) - if fill_h > 8: - rl.draw_rectangle_rounded(rl.Rectangle(fill_rect.x + 1, fill_rect.y + 1, fill_rect.width - 2, fill_rect.height - 2), 0.3, 10, rl.Color(255, 255, 255, 25)) + fill_rect = _snap_rect(rl.Rectangle(track_x, track_top + track_h - fill_h, track_w, fill_h)) + rl.draw_rectangle_rec(fill_rect, _with_alpha(self.color, 192)) - # Grab indicator at fill edge if 2 < fill_h < track_h - 2: edge_y = track_top + track_h - fill_h - rl.draw_rectangle_rec(rl.Rectangle(track_x + 6, edge_y - 1, track_w - 12, 3), rl.Color(255, 255, 255, 100)) + rl.draw_rectangle_rec(_snap_rect(rl.Rectangle(track_x + 6, edge_y - 1, track_w - 12, 2)), rl.Color(255, 255, 255, 96)) - # Active glow when dragging if self._is_dragging: - rl.draw_rectangle_rounded_lines_ex(self._track_rect, 0.3, 10, 2, self.color) + _draw_rounded_stroke(self._track_rect, _with_alpha(self.color, 88), thickness=2, radius_px=16) - # Value label (centered below track) val_str = self.labels.get(self.current_val, f"{self.current_val:.1f}{self.unit}" if isinstance(self.current_val, float) and self.base_step < 1 else f"{int(self.current_val)}{self.unit}") - vs = measure_text_cached(self._font, val_str, 18) + vs = measure_text_cached(self._font, val_str, value_size) vx = rect.x + (rect.width - vs.x) / 2 vy = track_top + track_h + gap - rl.draw_text_ex(self._font, val_str, rl.Vector2(round(vx), round(vy)), 18, 0, rl.WHITE) - + rl.draw_text_ex(self._font, val_str, rl.Vector2(round(vx), round(vy)), value_size, 0, rl.WHITE) diff --git a/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py b/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py index 5589c9eb7..3bfb8961f 100644 --- a/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/sectioned_panel.py @@ -5,10 +5,11 @@ from dataclasses import dataclass import pyray as rl from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget -from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import SPACING, TileGrid +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import SPACING, TileGrid, AetherScrollbar @dataclass(frozen=True) @@ -34,6 +35,10 @@ class SectionedTileLayout(Widget): self._title_font_size = 26 self._font_title = gui_app.font(FontWeight.BOLD) self._is_active = False + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._scrollbar = AetherScrollbar() + self._scroll_offset = 0.0 + self._content_height = 0.0 def set_sections(self, sections: list[TileSection]): if self._is_active: @@ -50,6 +55,7 @@ class SectionedTileLayout(Widget): def show_event(self): self._is_active = True super().show_event() + self._scroll_offset = 0.0 for section in self._sections: section.grid.show_event() @@ -59,11 +65,22 @@ class SectionedTileLayout(Widget): for section in self._sections: section.grid.hide_event() - def _title_block_height(self, section: TileSection) -> int: - return (self._title_height + self._title_gap) if section.title else 0 + def _content_height_for_layout(self, rect: rl.Rectangle, sections: list[TileSection], title_height: int, title_gap: int, section_gap: int, row_height: float) -> float: + total = self._top_padding + for index, section in enumerate(sections): + total += self._title_block_height(section, title_height, title_gap) + total += (section.grid.get_row_count(available_width=rect.width) * row_height) + section.grid.get_internal_gap_height(available_width=rect.width) + if index < len(sections) - 1: + total += section_gap + return total + + def _title_block_height(self, section: TileSection, title_height: int | None = None, title_gap: int | None = None) -> int: + resolved_title_height = self._title_height if title_height is None else title_height + resolved_title_gap = self._title_gap if title_gap is None else title_gap + return (resolved_title_height + resolved_title_gap) if section.title else 0 def _section_band_height(self, section: TileSection, row_height: float) -> float: - rows = section.grid.get_row_count() + rows = section.grid.get_row_count(available_width=self._content_rect(self._rect).width if self._rect.width else None) if rows <= 0: return 0.0 return (rows * row_height) + (section.grid.gap * max(0, rows - 1)) @@ -76,28 +93,36 @@ class SectionedTileLayout(Widget): content_x = rect.x + (rect.width - content_w) / 2 return rl.Rectangle(content_x, rect.y, content_w, rect.height) - def _compute_row_height(self, rect: rl.Rectangle, sections: list[TileSection]) -> float: - total_rows = sum(section.grid.get_row_count() for section in sections) + def _layout_profile(self, rect: rl.Rectangle, sections: list[TileSection]) -> tuple[int, int, int, float]: + total_rows = sum(section.grid.get_row_count(available_width=rect.width) for section in sections) if total_rows <= 0: - return 0.0 + return self._title_height, self._title_gap, self._section_gap, 0.0 - total_title_height = sum(self._title_block_height(section) for section in sections) - total_section_gaps = self._section_gap * max(0, len(sections) - 1) - total_internal_gaps = sum(section.grid.get_internal_gap_height() for section in sections) - # Clamp oversized sections so tiles keep a touch-friendly shape instead of stretching to fill the full panel. - fit_row_height = max(0.0, (rect.height - self._top_padding - total_title_height - total_section_gaps - total_internal_gaps) / total_rows) - if fit_row_height >= self._min_row_height: - return min(fit_row_height, self._max_row_height) - return fit_row_height + total_internal_gaps = sum(section.grid.get_internal_gap_height(available_width=rect.width) for section in sections) + title_height = self._title_height + title_gap = self._title_gap + section_gap = self._section_gap + + for compact_pass in range(2): + total_title_height = sum(self._title_block_height(section, title_height, title_gap) for section in sections) + total_section_gaps = section_gap * max(0, len(sections) - 1) + fit_row_height = max(0.0, (rect.height - self._top_padding - total_title_height - total_section_gaps - total_internal_gaps) / total_rows) + min_row_height = min(self._min_row_height, 124) if rect.width < 960 or compact_pass else self._min_row_height + if fit_row_height >= min_row_height: + return title_height, title_gap, section_gap, min(fit_row_height, self._max_row_height) + title_height = max(24, min(title_height, 28)) + title_gap = max(4, min(title_gap, 6)) + section_gap = max(12, min(section_gap, 16)) + + return title_height, title_gap, section_gap, fit_row_height def _draw_section_title(self, rect: rl.Rectangle, title: str): - title_text = title.upper() + title_text = title spacing = round(self._title_font_size * 0.08) size = measure_text_cached(self._font_title, title_text, self._title_font_size, spacing=spacing) text_y = rect.y + (rect.height - size.y) / 2 text_pos = rl.Vector2(round(rect.x), round(text_y)) - rl.draw_text_ex(self._font_title, title_text, rl.Vector2(text_pos.x + 1, text_pos.y + 1), self._title_font_size, spacing, rl.Color(0, 0, 0, 90)) - rl.draw_text_ex(self._font_title, title_text, text_pos, self._title_font_size, spacing, rl.Color(255, 255, 255, 215)) + rl.draw_text_ex(self._font_title, title_text, text_pos, self._title_font_size, spacing, rl.Color(236, 242, 250, 215)) line_x = rect.x + size.x + SPACING.lg line_w = rect.width - (line_x - rect.x) @@ -113,20 +138,34 @@ class SectionedTileLayout(Widget): return content_rect = self._content_rect(rect) - row_height = self._compute_row_height(content_rect, sections) + title_height, title_gap, section_gap, row_height = self._layout_profile(content_rect, sections) if row_height <= 0: return - y = content_rect.y + self._top_padding + self._content_height = self._content_height_for_layout(content_rect, sections, title_height, title_gap, section_gap, row_height) + self._scroll_panel.set_enabled(self.is_visible) + self._scroll_offset = self._scroll_panel.update(content_rect, max(self._content_height, content_rect.height)) if self._content_height > content_rect.height else 0.0 + + rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height)) + + y = content_rect.y + self._top_padding + self._scroll_offset for index, section in enumerate(sections): if section.title: - title_rect = rl.Rectangle(content_rect.x, y, content_rect.width, self._title_height) + title_rect = rl.Rectangle(content_rect.x, y, content_rect.width, title_height) self._draw_section_title(title_rect, section.title) - y += self._title_height + self._title_gap + y += title_height + title_gap - active_grid_height = (section.grid.get_row_count() * row_height) + section.grid.get_internal_gap_height() + active_grid_height = (section.grid.get_row_count(available_width=content_rect.width) * row_height) + section.grid.get_internal_gap_height(available_width=content_rect.width) + if hasattr(section.grid, "set_touch_valid_callback"): + section.grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) + section.grid.set_parent_rect(content_rect) section.grid.render(rl.Rectangle(content_rect.x, y, content_rect.width, active_grid_height)) y += self._section_band_height(section, row_height) if index < len(sections) - 1: - y += self._section_gap + y += section_gap + + rl.end_scissor_mode() + + if self._content_height > content_rect.height: + self._scrollbar.render(content_rect, self._content_height, self._scroll_offset) diff --git a/selfdrive/ui/layouts/settings/starpilot/system_settings.py b/selfdrive/ui/layouts/settings/starpilot/system_settings.py index 293f940ec..65f02c1d0 100644 --- a/selfdrive/ui/layouts/settings/starpilot/system_settings.py +++ b/selfdrive/ui/layouts/settings/starpilot/system_settings.py @@ -188,13 +188,21 @@ class SystemSettingsManagerView(Widget): content_w = frame.scroll.width section_gap = 24 - col_w = (content_w - section_gap) / 2 - left_col = rl.Rectangle(frame.scroll.x, content_y, col_w, content_h) - right_col = rl.Rectangle(frame.scroll.x + col_w + section_gap, content_y, col_w, content_h) - self._sync_slider_values() - self._draw_left_column(left_col) - self._draw_right_column(right_col) + if content_w < 1260: + left_ratio = 0.56 if content_w < 1080 else 0.52 + col_w = (content_w - section_gap) / 2 + left_col = rl.Rectangle(frame.scroll.x, content_y, col_w * left_ratio + col_w * 0.44, content_h) + right_col_x = left_col.x + left_col.width + section_gap + right_col = rl.Rectangle(right_col_x, content_y, frame.scroll.x + content_w - right_col_x, content_h) + self._draw_left_column(left_col) + self._draw_right_column(right_col, compact=True) + else: + col_w = (content_w - section_gap) / 2 + left_col = rl.Rectangle(frame.scroll.x, content_y, col_w, content_h) + right_col = rl.Rectangle(frame.scroll.x + col_w + section_gap, content_y, col_w, content_h) + self._draw_left_column(left_col) + self._draw_right_column(right_col, compact=False) def _sync_slider_values(self): params = self._controller._params @@ -208,11 +216,11 @@ class SystemSettingsManagerView(Widget): # --- Left column: slider bands + toggles --- def _draw_left_column(self, rect: rl.Rectangle): - band_zone_h = rect.height * 0.45 + band_zone_h = rect.height * (0.52 if rect.width < 760 else 0.45) toggle_zone_h = rect.height - band_zone_h - 16 - display_h = band_zone_h * 0.60 - power_h = band_zone_h * 0.40 - 16 + display_h = band_zone_h * 0.58 + power_h = band_zone_h - display_h - 16 self._draw_slider_band(rl.Rectangle(rect.x, rect.y, rect.width, display_h), tr("Display"), self._display_band) self._draw_slider_band(rl.Rectangle(rect.x, rect.y + display_h + 16, rect.width, power_h), tr("Power"), self._power_band) @@ -232,40 +240,50 @@ class SystemSettingsManagerView(Widget): slider_h = rect.height - label_h - 32 slider_area_w = rect.width - 32 gap = 12 - - # Force 4 columns mathematically so 2-item bands don't stretch into fat horizontal boxes - standard_n = 4 - col_w = (slider_area_w - (standard_n - 1) * gap) / standard_n - + n = len(slider_keys) - content_w = n * col_w + (n - 1) * gap + cols = max(1, min(n, int((slider_area_w + gap) / (120 + gap)))) + rows = (n + cols - 1) // cols + col_w = (slider_area_w - (cols - 1) * gap) / cols + row_gap = 12 + total_slider_h = max(0, slider_h - row_gap * max(0, rows - 1)) + cell_h = total_slider_h / max(1, rows) + content_w = min(n, cols) * col_w + max(0, min(n, cols) - 1) * gap start_x = rect.x + 16 + (slider_area_w - content_w) / 2 for i, key in enumerate(slider_keys): - col_x = start_x + i * (col_w + gap) - self._vsliders[key].render(rl.Rectangle(col_x, slider_top, col_w, slider_h)) + row = i // cols + col = i % cols + col_x = start_x + col * (col_w + gap) + col_y = slider_top + row * (cell_h + row_gap) + self._vsliders[key].render(rl.Rectangle(col_x, col_y, col_w, cell_h)) # --- Right column: action cards --- - def _draw_right_column(self, rect: rl.Rectangle): + def _draw_right_column(self, rect: rl.Rectangle, compact: bool = False): gap = 16 total_h = rect.height - 2 * gap - h1 = total_h * 0.30 - h2 = total_h * 0.40 - h3 = total_h * 0.30 + if compact: + h1 = total_h * 0.26 + h2 = total_h * 0.42 + h3 = total_h * 0.32 + else: + h1 = total_h * 0.30 + h2 = total_h * 0.40 + h3 = total_h * 0.30 card1_rect = rl.Rectangle(rect.x, rect.y, rect.width, h1) card2_rect = rl.Rectangle(rect.x, rect.y + h1 + gap, rect.width, h2) card3_rect = rl.Rectangle(rect.x, rect.y + h1 + h2 + 2*gap, rect.width, h3) self._draw_card_background(card1_rect, tr("Drive & Actions")) - self._draw_card1_content(card1_rect) + self._draw_card1_content(card1_rect, compact=compact) self._draw_card_background(card2_rect, tr("Backups Management")) - self._draw_card2_content(card2_rect) + self._draw_card2_content(card2_rect, compact=compact) self._draw_card_background(card3_rect, tr("Maintenance (Caution)")) - self._draw_card3_content(card3_rect) + self._draw_card3_content(card3_rect, compact=compact) def _draw_card_background(self, rect, title): rl.draw_rectangle_rounded(rect, 0.15, 16, rl.Color(28, 30, 36, 255)) @@ -291,65 +309,99 @@ class SystemSettingsManagerView(Widget): rl.draw_rectangle_rounded(rect, 0.25, 16, color) rl.draw_rectangle_rounded_lines_ex(rect, 0.25, 16, 1, border_color) text_color = rl.Color(255, 200, 200, 255) if danger else rl.WHITE - gui_label(rect, label, 24, text_color, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + font_size = max(14, min(22, int(min(rect.width, rect.height) * 0.24))) + gui_label(rect, label, font_size, text_color, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) - def _draw_card1_content(self, rect): - ACTION_BTN_H = 64 + def _draw_button_row(self, rect: rl.Rectangle, buttons: list[tuple[str, str, bool]], columns: int, *, gap: int = 16, button_h: int | None = None): + cols = max(1, min(columns, len(buttons))) + rows = (len(buttons) + cols - 1) // cols + resolved_button_h = button_h if button_h is not None else max(34, min(64, int((rect.height - gap * max(0, rows - 1)) / max(1, rows)))) + button_w = (rect.width - gap * max(0, cols - 1)) / cols + for idx, (action_id, label, danger) in enumerate(buttons): + row = idx // cols + col = idx % cols + bx = rect.x + col * (button_w + gap) + by = rect.y + row * (resolved_button_h + gap) + self._draw_action_button(rl.Rectangle(bx, by, button_w, resolved_button_h), action_id, label, danger=danger) + return rows * resolved_button_h + gap * max(0, rows - 1) + + def _draw_card1_content(self, rect, compact: bool = False): content_y = rect.y + 44 content_h = rect.height - 44 - 16 - - num_rows = 2 - gap = (content_h - (ACTION_BTN_H * num_rows)) / (num_rows + 1) - + if compact: + stacked = rect.width < 760 + units = 3 if stacked else 2 + gap = 10 + control_h = max(30, min(64, int((content_h - gap * (units + 1)) / units))) + else: + ACTION_BTN_H = 64 + gap = max(12, (content_h - (ACTION_BTN_H * 2)) / 3) + control_h = ACTION_BTN_H + ry = content_y + gap - radio_rect = rl.Rectangle(rect.x + 16, ry, rect.width - 32, ACTION_BTN_H) + radio_rect = rl.Rectangle(rect.x + 16, ry, rect.width - 32, control_h) self._drive_mode_radio.current_index = self._get_drive_mode_index() self._drive_mode_radio.render(radio_rect) - by = ry + ACTION_BTN_H + gap + by = ry + control_h + gap btn_w = (rect.width - 32 - 16) / 2 - self._draw_action_button(rl.Rectangle(rect.x + 16, by, btn_w, ACTION_BTN_H), "FlashPanda", tr("Flash Panda")) - self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, by, btn_w, ACTION_BTN_H), "ReportIssue", tr("Report Issue")) + if compact and rect.width < 760: + self._draw_button_row(rl.Rectangle(rect.x + 16, by, rect.width - 32, control_h * 2 + gap), [("FlashPanda", tr("Flash Panda"), False), ("ReportIssue", tr("Report Issue"), False)], 1, gap=gap, button_h=control_h) + else: + self._draw_action_button(rl.Rectangle(rect.x + 16, by, btn_w, control_h), "FlashPanda", tr("Flash Panda")) + self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, by, btn_w, control_h), "ReportIssue", tr("Report Issue")) - def _draw_card2_content(self, rect): - ACTION_BTN_H = 64 + def _draw_card2_content(self, rect, compact: bool = False): content_y = rect.y + 44 - content_h = rect.height - 44 - 16 - - num_rows = 2 - gap = (content_h - (ACTION_BTN_H * num_rows)) / (num_rows + 1) + if compact: + gap = 8 + label_h = 18 + cols = 2 if rect.width >= 560 else 1 + rows_per_group = (3 + cols - 1) // cols + total_rows = rows_per_group * 2 + available_h = rect.height - 44 - 16 - label_h * 2 - gap * 5 + button_h = max(28, min(56, int(available_h / max(1, total_rows)))) + else: + ACTION_BTN_H = 64 + gap = max(12, (rect.height - 44 - 16 - (ACTION_BTN_H * 2)) / 3) + label_h = ACTION_BTN_H + cols = 3 + rows_per_group = 1 + button_h = ACTION_BTN_H sys_y = content_y + gap - gui_label(rl.Rectangle(rect.x + 16, sys_y, 110, ACTION_BTN_H), tr("System:"), 22, rl.WHITE, FontWeight.SEMI_BOLD) - btn_w = (rect.width - 130 - 32 - 32) / 3 - start_x = rect.x + 130 - self._draw_action_button(rl.Rectangle(start_x, sys_y, btn_w, ACTION_BTN_H), "CreateBackup", tr("Create")) - self._draw_action_button(rl.Rectangle(start_x + btn_w + 16, sys_y, btn_w, ACTION_BTN_H), "RestoreBackup", tr("Restore")) - self._draw_action_button(rl.Rectangle(start_x + 2*(btn_w + 16), sys_y, btn_w, ACTION_BTN_H), "DeleteBackup", tr("Delete"), danger=True) + label_w = 110 if rect.width >= 760 and not compact else 92 + label_size = 22 if not compact else 18 + gui_label(rl.Rectangle(rect.x + 16, sys_y, label_w, label_h), tr("System:"), label_size, rl.WHITE, FontWeight.SEMI_BOLD) + start_x = rect.x + label_w + 20 + row_w = rect.width - (start_x - rect.x) - 16 + system_h = self._draw_button_row(rl.Rectangle(start_x, sys_y, row_w, rows_per_group * button_h + gap * max(0, rows_per_group - 1)), [("CreateBackup", tr("Create"), False), ("RestoreBackup", tr("Restore"), False), ("DeleteBackup", tr("Delete"), True)], cols, gap=gap, button_h=button_h) - tog_y = sys_y + ACTION_BTN_H + gap - gui_label(rl.Rectangle(rect.x + 16, tog_y, 110, ACTION_BTN_H), tr("Toggles:"), 22, rl.WHITE, FontWeight.SEMI_BOLD) - self._draw_action_button(rl.Rectangle(start_x, tog_y, btn_w, ACTION_BTN_H), "CreateToggleBackup", tr("Create")) - self._draw_action_button(rl.Rectangle(start_x + btn_w + 16, tog_y, btn_w, ACTION_BTN_H), "RestoreToggleBackup", tr("Restore")) - self._draw_action_button(rl.Rectangle(start_x + 2*(btn_w + 16), tog_y, btn_w, ACTION_BTN_H), "DeleteToggleBackup", tr("Delete"), danger=True) + tog_y = sys_y + max(label_h, system_h) + gap + gui_label(rl.Rectangle(rect.x + 16, tog_y, label_w, label_h), tr("Toggles:"), label_size, rl.WHITE, FontWeight.SEMI_BOLD) + self._draw_button_row(rl.Rectangle(start_x, tog_y, row_w, rows_per_group * button_h + gap * max(0, rows_per_group - 1)), [("CreateToggleBackup", tr("Create"), False), ("RestoreToggleBackup", tr("Restore"), False), ("DeleteToggleBackup", tr("Delete"), True)], cols, gap=gap, button_h=button_h) - def _draw_card3_content(self, rect): - ACTION_BTN_H = 64 + def _draw_card3_content(self, rect, compact: bool = False): content_y = rect.y + 44 - content_h = rect.height - 44 - 16 - - num_rows = 2 - gap = (content_h - (ACTION_BTN_H * num_rows)) / (num_rows + 1) - - btn_w = (rect.width - 32 - 16) / 2 + if compact: + gap = 10 + columns = 2 if rect.width >= 520 else 1 + rows_per_group = (2 + columns - 1) // columns + available_h = rect.height - 44 - 16 - gap * 3 + button_h = max(30, min(56, int(available_h / max(2, rows_per_group * 2)))) + else: + ACTION_BTN_H = 64 + gap = max(12, (rect.height - 44 - 16 - (ACTION_BTN_H * 2)) / 3) + columns = 2 + rows_per_group = 1 + button_h = ACTION_BTN_H sys_y = content_y + gap - self._draw_action_button(rl.Rectangle(rect.x + 16, sys_y, btn_w, ACTION_BTN_H), "Storage", tr("Clear Data"), danger=True) - self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, sys_y, btn_w, ACTION_BTN_H), "ErrorLogs", tr("Clear Logs"), danger=True) + group_h = rows_per_group * button_h + gap * max(0, rows_per_group - 1) + system_h = self._draw_button_row(rl.Rectangle(rect.x + 16, sys_y, rect.width - 32, group_h), [("Storage", tr("Clear Data"), True), ("ErrorLogs", tr("Clear Logs"), True)], columns, gap=gap, button_h=button_h) - tog_y = sys_y + ACTION_BTN_H + gap - self._draw_action_button(rl.Rectangle(rect.x + 16, tog_y, btn_w, ACTION_BTN_H), "ResetDefaults", tr("Reset Toggles"), danger=True) - self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, tog_y, btn_w, ACTION_BTN_H), "ResetStock", tr("Stock OP"), danger=True) + tog_y = sys_y + system_h + gap + self._draw_button_row(rl.Rectangle(rect.x + 16, tog_y, rect.width - 32, group_h), [("ResetDefaults", tr("Reset Toggles"), True), ("ResetStock", tr("Stock OP"), True)], columns, gap=gap, button_h=button_h) class StarPilotSystemLayout(StarPilotPanel): def __init__(self): diff --git a/selfdrive/ui/tests/test_aethergrid.py b/selfdrive/ui/tests/test_aethergrid.py new file mode 100644 index 000000000..9ba9ec025 --- /dev/null +++ b/selfdrive/ui/tests/test_aethergrid.py @@ -0,0 +1,359 @@ +import importlib +import sys +import types +import unittest +from unittest.mock import MagicMock + + +MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid" +PANEL_MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.panel" +SECTIONED_PANEL_MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel" + + +def _clear_modules(*module_names): + for module_name in module_names: + sys.modules.pop(module_name, None) + + +def _register_modules(module_map): + for name, module in module_map.items(): + sys.modules[name] = module + + +def _make_texture(width=80, height=80): + return types.SimpleNamespace(width=width, height=height) + + +def _install_aethergrid_stubs(): + rl = types.SimpleNamespace( + Color=lambda r, g, b, a=255: types.SimpleNamespace(r=r, g=g, b=b, a=a), + Rectangle=lambda x=0, y=0, width=0, height=0: types.SimpleNamespace(x=x, y=y, width=width, height=height), + Vector2=lambda x=0, y=0: types.SimpleNamespace(x=x, y=y), + Texture2D=type("Texture2D", (), {}), + Font=type("Font", (), {}), + GuiTextAlignment=types.SimpleNamespace(TEXT_ALIGN_CENTER=0), + WHITE=types.SimpleNamespace(r=255, g=255, b=255, a=255), + draw_rectangle_rounded=lambda *a, **k: None, + draw_rectangle_rounded_lines_ex=lambda *a, **k: None, + draw_rectangle_rec=lambda *a, **k: None, + draw_rectangle=lambda *a, **k: None, + draw_rectangle_gradient_v=lambda *a, **k: None, + draw_ring=lambda *a, **k: None, + draw_circle=lambda *a, **k: None, + draw_line=lambda *a, **k: None, + draw_line_ex=lambda *a, **k: None, + 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, + get_frame_time=lambda: 0.016, + get_mouse_position=lambda: types.SimpleNamespace(x=0, y=0), + ) + sys.modules["pyray"] = rl + + app_mod = types.ModuleType("openpilot.system.ui.lib.application") + app_mod.FontWeight = types.SimpleNamespace(BOLD=700, NORMAL=400, MEDIUM=500, SEMI_BOLD=600) + app_mod.MousePos = type("MousePos", (), {}) + app_mod.MouseEvent = type("MouseEvent", (), {}) + app_mod.gui_app = types.SimpleNamespace( + width=1920, + height=1080, + last_mouse_event=types.SimpleNamespace(pos=types.SimpleNamespace(x=0, y=0)), + font=lambda *_a, **_k: object(), + texture=lambda *_a, **_k: _make_texture(), + pop_widget=lambda: None, + ) + sys.modules["openpilot.system.ui.lib.application"] = app_mod + + scroll_panel_mod = types.ModuleType("openpilot.system.ui.lib.scroll_panel2") + class GuiScrollPanel2: + def __init__(self, horizontal=True, handle_out_of_bounds=True): + self._enabled = True + self._offset = 0.0 + + def set_enabled(self, enabled): + self._enabled = enabled + + def update(self, bounds, content_size): + return 0.0 + + def is_touch_valid(self): + return True + + scroll_panel_mod.GuiScrollPanel2 = GuiScrollPanel2 + sys.modules["openpilot.system.ui.lib.scroll_panel2"] = scroll_panel_mod + + multilang_mod = types.ModuleType("openpilot.system.ui.lib.multilang") + multilang_mod.tr = lambda text: text + sys.modules["openpilot.system.ui.lib.multilang"] = multilang_mod + + text_measure_mod = types.ModuleType("openpilot.system.ui.lib.text_measure") + text_measure_mod.measure_text_cached = lambda *_a, **_k: types.SimpleNamespace(x=100, y=20) + sys.modules["openpilot.system.ui.lib.text_measure"] = text_measure_mod + + widgets_mod = types.ModuleType("openpilot.system.ui.widgets") + + class Widget: + def __init__(self): + self._rect = rl.Rectangle() + self._parent_rect = None + self._enabled = True + self.is_pressed = False + + @property + def enabled(self): + return self._enabled() if callable(self._enabled) else self._enabled + + def set_rect(self, rect): + self._rect = rect + + def set_parent_rect(self, rect): + self._parent_rect = rect + + def render(self, rect): + self._rect = rect + return self._render(rect) + + def set_click_callback(self, callback): + self.on_click = callback + + def set_enabled(self, enabled): + self._enabled = enabled + + def _render(self, rect): + return None + + widgets_mod.Widget = Widget + widgets_mod.DialogResult = types.SimpleNamespace(CONFIRM=1, CANCEL=0, NO_ACTION=-1) + sys.modules["openpilot.system.ui.widgets"] = widgets_mod + + label_mod = types.ModuleType("openpilot.system.ui.widgets.label") + label_mod.gui_label = lambda *a, **k: None + sys.modules["openpilot.system.ui.widgets.label"] = label_mod + + asset_loader_mod = types.ModuleType("openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader") + asset_loader_mod.starpilot_texture = lambda *_a, **_k: _make_texture() + sys.modules["openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader"] = asset_loader_mod + + +def _install_panel_stubs(aethergrid): + sectioned_mod = types.ModuleType(SECTIONED_PANEL_MODULE_NAME) + sectioned_mod.SectionedTileLayout = type("SectionedTileLayout", (), {}) + sectioned_mod.TileSection = type("TileSection", (), {}) + + _register_modules({ + SECTIONED_PANEL_MODULE_NAME: sectioned_mod, + "openpilot.common.params": types.SimpleNamespace(Params=type("Params", (), {})), + MODULE_NAME: aethergrid, + }) + + +def _import_module(module_name): + _install_aethergrid_stubs() + _clear_modules(module_name) + return importlib.import_module(module_name) + + +def _import_aethergrid(): + return _import_module(MODULE_NAME) + + +def _import_panel(monkeypatch_module=None): + aethergrid = _import_aethergrid() + _install_panel_stubs(aethergrid) + _clear_modules(PANEL_MODULE_NAME) + return importlib.import_module(PANEL_MODULE_NAME) + + +def _import_real_sectioned_panel(): + _install_aethergrid_stubs() + _clear_modules(SECTIONED_PANEL_MODULE_NAME) + return importlib.import_module(SECTIONED_PANEL_MODULE_NAME) + + +class RenderSpy: + def __init__(self): + self.rects = [] + self.parent_rect = None + + def render(self, rect): + self.rects.append(rect) + + def show_event(self): + pass + + def hide_event(self): + pass + + def set_parent_rect(self, rect): + self.parent_rect = rect + + +class TestAethergridContracts(unittest.TestCase): + def test_aethergrid_module_imports_with_headless_stubs(self): + mod = _import_aethergrid() + + self.assertTrue(hasattr(mod, "TileGrid")) + self.assertTrue(hasattr(mod, "HubTile")) + self.assertTrue(hasattr(mod, "ToggleTile")) + self.assertTrue(hasattr(mod, "ValueTile")) + self.assertTrue(hasattr(mod, "RadioTileGroup")) + self.assertTrue(hasattr(mod, "AetherSliderDialog")) + + + def test_tile_grid_column_contract_stays_stable(self): + mod = _import_aethergrid() + grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap) + + self.assertEqual(grid.get_column_count(1), 1) + self.assertEqual(grid.get_column_count(2), 2) + self.assertEqual(grid.get_column_count(3), 3) + self.assertEqual(grid.get_column_count(4), 2) + self.assertEqual(grid.get_column_count(5), 3) + self.assertEqual(grid.get_column_count(7), 4) + + + def test_build_list_panel_frame_contract(self): + mod = _import_aethergrid() + frame = mod.build_list_panel_frame(mod.rl.Rectangle(0, 0, 1920, 1080)) + + self.assertGreater(frame.shell.width, 0) + self.assertEqual(frame.header.height, mod.AETHER_LIST_METRICS.header_height) + self.assertGreater(frame.scroll.height, 0) + + + def test_hit_rect_keeps_touch_slop_after_surface_flattening(self): + mod = _import_aethergrid() + tile = mod.AetherTile(surface_color="#3B82F6") + tile.set_rect(mod.rl.Rectangle(100, 200, 300, 150)) + hit = tile._hit_rect + + self.assertGreater(hit.width, tile._rect.width) + self.assertGreater(hit.height, tile._rect.height) + + + def test_aether_tile_uses_single_planar_face_contract(self): + mod = _import_aethergrid() + tile = mod.AetherTile(surface_color="#3B82F6") + face = tile._surface_rect(mod.rl.Rectangle(0, 0, 320, 160)) + + self.assertLess(face.width, 320) + self.assertLess(face.height, 160) + self.assertGreaterEqual(face.x, 0) + self.assertGreaterEqual(face.y, 0) + + def test_aether_tile_surface_rect_snaps_to_integer_pixels(self): + mod = _import_aethergrid() + tile = mod.AetherTile(surface_color="#3B82F6") + face = tile._surface_rect(mod.rl.Rectangle(0.5, 0.5, 320.25, 160.75)) + + self.assertEqual(face.x, round(face.x)) + self.assertEqual(face.y, round(face.y)) + self.assertEqual(face.width, round(face.width)) + self.assertEqual(face.height, round(face.height)) + + def test_aether_tile_preserves_substrate_color_attribute_for_compatibility(self): + mod = _import_aethergrid() + substrate = mod.hex_to_color("#101820") + tile = mod.AetherTile(surface_color="#3B82F6", substrate_color=substrate) + + self.assertIs(tile.substrate_color, substrate) + + + def test_hub_tile_preserves_status_progress_api(self): + mod = _import_aethergrid() + tile = mod.HubTile("Driving Controls", "Desc", "", bg_color="#3B82F6", get_status=lambda: "Download 50%") + + self.assertEqual(tile.get_status(), "Download 50%") + + def test_tile_stack_layout_keeps_full_content_block_inside_face(self): + mod = _import_aethergrid() + tile = mod.AetherTile(surface_color="#3B82F6") + face = mod.rl.Rectangle(0, 0, 320, 180) + layout = tile._measure_tile_stack(face, icon_height=60, title_lines=2, title_size=28, primary_size=30, desc_lines=2, desc_size=18) + + self.assertGreaterEqual(layout["top"], 0) + self.assertLessEqual(layout["desc_bottom"], face.height) + + def test_tile_grid_reflows_to_wider_tiles_when_width_is_tight(self): + mod = _import_aethergrid() + grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap) + + spies = [RenderSpy() for _ in range(7)] + for spy in spies: + grid.add_tile(spy) + + grid.render(mod.rl.Rectangle(0, 0, 700, 500)) + + self.assertTrue(spies[0].rects) + self.assertGreater(spies[0].rects[0].width, 300) + + + def test_toggle_and_value_tiles_keep_enabled_contract(self): + mod = _import_aethergrid() + toggle = mod.ToggleTile("Feature", lambda: True, lambda _v: None, is_enabled=lambda: False) + value = mod.ValueTile("Value", lambda: "42", lambda: None, is_enabled=lambda: False) + + self.assertFalse(toggle.enabled) + self.assertFalse(value.enabled) + + def test_disabled_value_tile_does_not_trigger_click(self): + mod = _import_aethergrid() + on_click = MagicMock() + value = mod.ValueTile("Value", lambda: "42", on_click, is_enabled=lambda: False) + value.set_rect(mod.rl.Rectangle(0, 0, 300, 150)) + + value._handle_mouse_press(types.SimpleNamespace(x=10, y=10)) + value._handle_mouse_release(types.SimpleNamespace(x=10, y=10)) + + on_click.assert_not_called() + + + def test_slider_dialog_keeps_color_and_callback_contract(self): + mod = _import_aethergrid() + captured = [] + dialog = mod.AetherSliderDialog("Test", 0, 10, 1, 5, lambda result, value: captured.append((result, value)), color="#8B5CF6") + + self.assertGreaterEqual(dialog._color.r, 0) + self.assertEqual(dialog._current_val, 5) + + + def test_radio_tile_group_preserves_selection_api(self): + mod = _import_aethergrid() + group = mod.RadioTileGroup("", ["A", "B", "C"], 1, lambda idx: None) + group.set_index(2) + + self.assertEqual(group.current_index, 2) + + def test_zero_span_sliders_do_not_divide_by_zero(self): + mod = _import_aethergrid() + slider = mod.AetherSlider(5, 5, 1, 5, lambda _v: None) + thumb_x = slider._get_thumb_x(mod.rl.Rectangle(0, 0, 320, 80)) + + self.assertGreaterEqual(thumb_x, 0) + + def test_panel_module_can_import_against_refactored_aethergrid(self): + panel_mod = _import_panel() + + self.assertTrue(hasattr(panel_mod, "StarPilotPanel")) + self.assertTrue(hasattr(panel_mod, "create_tile_panel")) + + def test_sectioned_layout_short_height_uses_scrollable_compact_profile(self): + mod = _import_aethergrid() + sectioned_mod = _import_real_sectioned_panel() + + layout = sectioned_mod.SectionedTileLayout() + grid = mod.TileGrid(columns=2, padding=mod.SPACING.tile_gap, uniform_width=True) + + for _ in range(6): + grid.add_tile(RenderSpy()) + + layout.set_sections([sectioned_mod.TileSection("Test", grid)]) + title_h, title_gap, section_gap, row_h = layout._layout_profile(mod.rl.Rectangle(0, 0, 800, 220), layout._sections) + + self.assertGreaterEqual(row_h, 0) + self.assertLessEqual(title_h, layout._title_height) + + +if __name__ == "__main__": + unittest.main()