BigUI WIP: Russian Nesting Tiles

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