BigUI WIP: K, we'll start simpler

This commit is contained in:
firestarsdog
2026-06-30 18:52:31 -04:00
parent 30786472cb
commit 97c20744d3
7 changed files with 853 additions and 926 deletions
@@ -36,6 +36,31 @@ _CONST_TERTIARY = rl.Color(145, 155, 175, 255)
_NODE_NUM_MIN = 3
_NODE_NUM_MAX = 5
_GLOBAL_SCISSOR_LIMIT: rl.Rectangle | None = None
def aether_begin_scissor_mode(x: int, y: int, w: int, h: int) -> None:
global _GLOBAL_SCISSOR_LIMIT
if _GLOBAL_SCISSOR_LIMIT is not None:
limit = _GLOBAL_SCISSOR_LIMIT
ix1 = max(x, int(limit.x))
iy1 = max(y, int(limit.y))
ix2 = min(x + w, int(limit.x + limit.width))
iy2 = min(y + h, int(limit.y + limit.height))
iw = max(0, ix2 - ix1)
ih = max(0, iy2 - iy1)
rl.begin_scissor_mode(ix1, iy1, iw, ih)
else:
rl.begin_scissor_mode(x, y, w, h)
def aether_end_scissor_mode() -> None:
global _GLOBAL_SCISSOR_LIMIT
if _GLOBAL_SCISSOR_LIMIT is not None:
limit = _GLOBAL_SCISSOR_LIMIT
rl.begin_scissor_mode(int(limit.x), int(limit.y), int(limit.width), int(limit.height))
else:
rl.end_scissor_mode()
# Custom vector icon layout constants (scribble.py coordinate system)
CUSTOM_ICON_BASE_SIZE = 100.0
CUSTOM_ICON_SCALE_MULT = 1.25
@@ -637,11 +662,11 @@ class PanelManagerView(AetherInteractiveMixin, Widget):
if scroll_disabled:
self._scroll_offset = 0.0
rl.begin_scissor_mode(
aether_begin_scissor_mode(
int(scroll_rect.x), int(scroll_rect.y),
int(scroll_rect.width), int(scroll_rect.height))
self._draw_scroll_content(scroll_rect, content_width)
rl.end_scissor_mode()
aether_end_scissor_mode()
self._draw_static_elements(scroll_rect, content_width)
@@ -769,13 +794,13 @@ class PanelManagerView(AetherInteractiveMixin, Widget):
def _page_scissor_push(self, rect: rl.Rectangle | None) -> None:
if rect is None:
return
rl.end_scissor_mode()
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
aether_end_scissor_mode()
aether_begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
def _page_scissor_pop(self) -> None:
rl.end_scissor_mode()
rl.begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y),
int(self._scroll_rect.width), int(self._scroll_rect.height))
aether_end_scissor_mode()
aether_begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y),
int(self._scroll_rect.width), int(self._scroll_rect.height))
# ── drag + animation ───────────────────────────────────────
@@ -2834,10 +2859,10 @@ class AetherSettingsView(PanelManagerView):
if scroll_disabled:
self._scroll_offset = 0.0
rl.begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y),
int(self._scroll_rect.width), int(self._scroll_rect.height))
aether_begin_scissor_mode(int(self._scroll_rect.x), int(self._scroll_rect.y),
int(self._scroll_rect.width), int(self._scroll_rect.height))
self._draw_scroll_content(self._scroll_rect, content_width)
rl.end_scissor_mode()
aether_end_scissor_mode()
if self._content_height > self._scroll_rect.height and not scroll_disabled:
self._scrollbar.render(self._scroll_rect, self._content_height, self._scroll_offset)
@@ -3073,169 +3098,121 @@ def draw_back_button(pill_rect: rl.Rectangle, center_y: float, pressed: bool, ho
BACK_BTN = "__back__"
# ── AetherCategoryTileView — dynamic nesting doll tile view ──
# ── AetherCategoryDrawer — touch-optimized sliding drawer ──
class AetherCategoryTileView(AetherSettingsView):
"""Reusable nested tile view that maps SettingRows to interactive tiles."""
class AetherCategoryDrawer(AetherSettingsView):
"""Reusable nested drawer view that maps SettingRows to a slide-out vertical settings list."""
def __init__(self, controller, title: str, rows: list[SettingRow],
*, color: rl.Color | str = "#8B5CF6", subtitle: str = "",
panel_style=None):
super().__init__(controller, [], header_title=title, header_subtitle=subtitle, panel_style=panel_style)
# Group the rows in a single SettingSection with an empty title
sections = [SettingSection(title="", rows=rows)]
super().__init__(controller, sections, header_title=title, header_subtitle=subtitle, panel_style=panel_style)
self._color = hex_to_color(color) if isinstance(color, str) else color
self._rows = rows
self._scroll_panel = GuiScrollPanel2(horizontal=True)
self._scroll_panel.snap_interval = 364.0
self._tile_grid = TileGrid(padding=16, tile_height=178.0, carousel_rows=3, carousel_tile_width=348.0)
self._tile_grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
self._child(self._tile_grid)
self._row_to_tile_map = {}
for row in self._rows:
tile = self._map_row_to_tile(row)
if tile is not None:
self._row_to_tile_map[row.id] = tile
self._slide_progress = 0.0
self._back_btn_rect = None
def _map_row_to_tile(self, row: SettingRow) -> Widget | None:
enabled_fn = row.enabled if row.enabled is not None else (lambda: True)
subtitle_text = row.subtitle
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scrollbar = AetherScrollbar()
if row.type == "toggle":
return RowToggleTile(
title=tr(row.title),
get_state=row.get_state,
set_state=row.set_state,
bg_color=self._color,
desc=tr(subtitle_text),
is_enabled=enabled_fn,
disabled_label=tr(row.disabled_label) if row.disabled_label else "",
)
elif row.type == "value":
return RowPanelTile(
title=tr(row.title),
get_status=row.get_value,
on_click=row.on_click,
bg_color=self._color,
desc=tr(subtitle_text),
)
elif row.type == "action":
return RowPanelTile(
title=tr(row.title),
get_status=lambda: tr(row.action_text) if hasattr(row, 'action_text') and row.action_text else "",
on_click=row.on_click,
bg_color=self._color,
desc=tr(subtitle_text),
)
return None
# Read driving side dynamically for ergonomic layout (LHD vs RHD)
try:
from openpilot.common.params import Params
self._is_rhd = Params().get_bool("IsRHD")
except Exception:
self._is_rhd = False
def _visible_rows(self) -> list[SettingRow]:
def _visible_rows(self, section: SettingSection) -> list[SettingRow]:
return [row for row in self._rows if row.visible is None or row.visible()]
def _update_visible_tiles(self):
visible_rows = self._visible_rows()
visible_ids = [row.id for row in visible_rows]
if getattr(self, "_last_visible_ids", None) == visible_ids:
return
self._last_visible_ids = visible_ids
self._tile_grid.clear()
for row in visible_rows:
tile = self._row_to_tile_map.get(row.id)
if tile is not None:
self._tile_grid.add_tile(tile)
def _measure_content_height(self, width: float) -> float:
self._update_visible_tiles()
return self._tile_grid.measure_height(width)
def _render(self, rect: rl.Rectangle):
self.set_rect(rect)
self._interactive_rects.clear()
# Dim background outside the dialog
# Dim the screen area outside the drawer
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(0, 0, 0, 160))
dialog_w = 1600
dialog_h = 750
dx = rect.x + (rect.width - dialog_w) / 2
dy = rect.y + (rect.height - dialog_h) / 2
# Drawer slide-in animation (exponential smoothing)
dt = rl.get_frame_time()
self._slide_progress += (1.0 - self._slide_progress) * (1.0 - math.exp(-dt / 0.12))
# Draw custom dialog background and top color band
d_rect = snap_rect(rl.Rectangle(dx, dy, dialog_w, dialog_h))
draw_rounded_fill(d_rect, rl.Color(10, 12, 16, 255), radius_px=24)
draw_rounded_stroke(d_rect, rl.Color(255, 255, 255, 16), radius_px=24)
rl.draw_rectangle_rec(rl.Rectangle(d_rect.x, d_rect.y, d_rect.width, 3), self._color)
drawer_w = 850
if self._is_rhd:
# RHD: slide out from the right (driver side)
drawer_x = rect.x + rect.width - drawer_w * self._slide_progress
drawer_rect = rl.Rectangle(drawer_x, rect.y + 12, drawer_w - 12, rect.height - 24)
else:
# LHD: slide out from the left (driver side)
drawer_x = rect.x - drawer_w + drawer_w * self._slide_progress
drawer_rect = rl.Rectangle(drawer_x + 12, rect.y + 12, drawer_w - 12, rect.height - 24)
header_rect = rl.Rectangle(dx + 60, dy + 24, dialog_w - 120, 100)
# Draw drawer background and border
draw_rounded_fill(drawer_rect, rl.Color(12, 10, 18, 250), radius_px=24)
draw_rounded_stroke(drawer_rect, rl.Color(255, 255, 255, 18), radius_px=24)
# Draw accent stripe on the side separating it from the dimmed area
if self._is_rhd:
rl.draw_rectangle_rec(rl.Rectangle(drawer_rect.x, drawer_rect.y, 4, drawer_rect.height), self._color)
else:
rl.draw_rectangle_rec(rl.Rectangle(drawer_rect.x + drawer_rect.width - 4, drawer_rect.y, 4, drawer_rect.height), self._color)
# Draw header with navigation breadcrumbs
header_rect = rl.Rectangle(drawer_rect.x + 36, drawer_rect.y + 24, drawer_rect.width - 72, 80)
if self._has_header:
self._draw_header(header_rect)
# Configure precise margins for the scroll area (80px sides)
self._scroll_rect = rl.Rectangle(dx + 80, dy + 140, dialog_w - 160, dialog_h - 180)
# Set up scroll area (vertical scroll list)
self._scroll_rect = rl.Rectangle(
drawer_rect.x + 36,
drawer_rect.y + 120,
drawer_rect.width - 72,
drawer_rect.height - 144
)
self._update_visible_tiles()
content_width_needed = self._tile_grid.measure_width()
content_height_needed = self._tile_grid.measure_height(self._scroll_rect.width)
scrolling_enabled = self.is_visible and (content_width_needed > self._scroll_rect.width)
self._scroll_panel.set_enabled(scrolling_enabled)
content_width = self._scroll_rect.width - AETHER_LIST_METRICS.content_right_gutter
self._content_height = self._measure_content_height(content_width)
self._scroll_panel.set_enabled(self.is_visible)
self._scroll_offset = self._scroll_panel.update(
self._scroll_rect, max(content_width_needed, self._scroll_rect.width))
x_pad = 12
y_pad = 24
rl.begin_scissor_mode(int(self._scroll_rect.x - x_pad), int(self._scroll_rect.y - y_pad),
int(self._scroll_rect.width + x_pad * 2), int(self._scroll_rect.height + y_pad * 2))
self._tile_grid._parent_rect = self._scroll_rect
y_margin = max(0, (self._scroll_rect.height - content_height_needed) / 2)
x_margin = max(0, (self._scroll_rect.width - content_width_needed) / 2)
grid_rect = rl.Rectangle(
self._scroll_rect.x + self._scroll_offset + x_margin,
self._scroll_rect.y + y_margin,
max(content_width_needed, self._scroll_rect.width),
self._scroll_rect.height
self._scroll_rect, max(self._content_height, self._scroll_rect.height)
)
self._tile_grid.render(grid_rect)
rl.end_scissor_mode()
# Draw horizontal scroll indicator glows on the sides using the thematic color
if scrolling_enabled:
glow_w = 120
fade_dist = 100.0
left_remaining = -self._scroll_offset
right_remaining = (content_width_needed - self._scroll_rect.width) + self._scroll_offset
left_alpha = int(max(0.0, min(1.0, left_remaining / fade_dist)) * 60)
right_alpha = int(max(0.0, min(1.0, right_remaining / fade_dist)) * 60)
glow_y = int(d_rect.y)
glow_h = int(d_rect.height)
if left_alpha > 0:
rl.draw_rectangle_gradient_h(
int(d_rect.x + 2), glow_y, glow_w, glow_h,
with_alpha(self._color, left_alpha), with_alpha(self._color, 0)
)
if right_alpha > 0:
rl.draw_rectangle_gradient_h(
int(d_rect.x + d_rect.width - glow_w - 2), glow_y, glow_w, glow_h,
with_alpha(self._color, 0), with_alpha(self._color, right_alpha)
)
# Scissor and render settings rows
aether_begin_scissor_mode(
int(self._scroll_rect.x), int(self._scroll_rect.y),
int(self._scroll_rect.width), int(self._scroll_rect.height)
)
self._draw_scroll_content(self._scroll_rect, content_width)
aether_end_scissor_mode()
if self._content_height > self._scroll_rect.height:
self._scrollbar.render(self._scroll_rect, self._content_height, self._scroll_offset)
draw_list_scroll_fades(
self._scroll_rect, self._content_height, self._scroll_offset,
rl.Color(12, 10, 18, 250), fade_height=self._fade_height
)
def _target_at(self, mouse_pos: MousePos) -> str | None:
if self._back_btn_rect and point_hits(mouse_pos, self._back_btn_rect, None, pad_x=8, pad_y=8):
return BACK_BTN
# Tap outside the drawer to dismiss
drawer_w = 850
if self._is_rhd:
drawer_x = self._rect.x + self._rect.width - drawer_w * self._slide_progress
if mouse_pos.x < drawer_x:
return "__dismiss__"
else:
drawer_x = self._rect.x - drawer_w + drawer_w * self._slide_progress
if mouse_pos.x > drawer_x + drawer_w:
return "__dismiss__"
return super()._target_at(mouse_pos)
def _activate_target(self, target_id: str | None):
if target_id == BACK_BTN:
if target_id == BACK_BTN or target_id == "__dismiss__":
gui_app.pop_widget()
else:
super()._activate_target(target_id)
@@ -3260,6 +3237,120 @@ class AetherCategoryTileView(AetherSettingsView):
self._breadcrumbs.draw(crumb_rect)
# Compatibility Alias
AetherCategoryTileView = AetherCategoryDrawer
# ── AetherTransitionManager — Spatial Parallax Page Transitions ──
class AetherTransitionManager:
def __init__(self, duration: float = 0.24):
self.duration = duration
self._time = 0.0
self._progress = 1.0
self._direction = 1 # 1 = forward (right to left), -1 = backward (left to right)
self._active = False
self._outgoing_render_fn = None
self._incoming_render_fn = None
def start(self, outgoing_render_fn, incoming_render_fn, direction: int):
self._outgoing_render_fn = outgoing_render_fn
self._incoming_render_fn = incoming_render_fn
self._direction = direction
self._time = 0.0
self._progress = 0.0
self._active = True
def is_animating(self) -> bool:
return self._active
def update(self, dt: float):
if not self._active:
return
# Cap dt to avoid large visual jumps on frame spikes
dt = min(0.016, dt)
self._time += dt
t = self._time / self.duration
if t >= 1.0:
t = 1.0
self._progress = 1.0
self._active = False
self._outgoing_render_fn = None
self._incoming_render_fn = None
else:
# Ease-in-out cubic curve (zero initial velocity, smooth acceleration & deceleration)
if t < 0.5:
self._progress = 4.0 * t * t * t
else:
self._progress = 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0
def render(self, rect: rl.Rectangle):
if not self._active:
return
global _GLOBAL_SCISSOR_LIMIT
_GLOBAL_SCISSOR_LIMIT = rect
# 1. Enforce strict content boundary clipping to block drawing over the sidebar on the left
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
# Clear the transition area with solid background to prevent ghosting/bleeding of transparent areas
rl.draw_rectangle_rec(rect, rl.Color(12, 10, 18, 255))
# Use progress directly since the exponential decay is already eased
eased = self._progress
# Outgoing and incoming rect calculations
if self._direction == 1:
# Forward: incoming slides right to left, outgoing slides left slightly (parallax)
out_x = rect.x - 0.25 * rect.width * eased
in_x = rect.x + rect.width * (1.0 - eased)
else:
# Backward: incoming slides left to right, outgoing slides right slightly (parallax)
out_x = rect.x + 0.25 * rect.width * eased
in_x = rect.x - rect.width * (1.0 - eased)
out_rect = rl.Rectangle(out_x, rect.y, rect.width, rect.height)
in_rect = rl.Rectangle(in_x, rect.y, rect.width, rect.height)
# 2. Render outgoing content
if self._outgoing_render_fn:
self._outgoing_render_fn(out_rect)
# Re-assert content scissor clip after child finishes rendering to override any nested disable calls
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
# 3. Draw dimming overlay on outgoing content
dim_alpha = int(120 * (1.0 - eased))
if dim_alpha > 0:
rl.draw_rectangle_rec(out_rect, rl.Color(0, 0, 0, dim_alpha))
# 4. Render incoming content
if self._incoming_render_fn:
self._incoming_render_fn(in_rect)
# Re-assert content scissor clip after child finishes rendering
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
# 5. Draw edge shadow/glow on incoming edge to separate layers
shadow_w = 40
if self._direction == 1:
rl.draw_rectangle_gradient_h(
int(in_rect.x - shadow_w), int(in_rect.y), shadow_w, int(in_rect.height),
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 100)
)
else:
rl.draw_rectangle_gradient_h(
int(in_rect.x + in_rect.width), int(in_rect.y), shadow_w, int(in_rect.height),
rl.Color(0, 0, 0, 100), rl.Color(0, 0, 0, 0)
)
# 6. Disable scissor globally when exiting transition rendering and clear global limit
rl.end_scissor_mode()
_GLOBAL_SCISSOR_LIMIT = None
class AetherTile(Widget):
def __init__(self, surface_color: rl.Color | str | None = None, substrate_color: rl.Color | str | None = None, on_click: Callable | None = None):
super().__init__()
@@ -18,7 +18,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SettingRow,
SettingSection,
AetherSettingsView,
AetherCategoryTileView,
AetherCategoryDrawer,
TileGrid,
HubTile,
draw_list_group_shell,
@@ -86,48 +86,21 @@ class AppearanceManagerView(AetherSettingsView):
"desc": tr("Customize dynamic lane paths, road edges, and colors."),
"icon": "steering",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Model & Path Visualization"),
self._controller._model_rows,
color="#8B5CF6",
subtitle=tr("Customize dynamic lane paths, road edges, and colors."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("model")
},
{
"title": tr("Driving Widgets & HUD"),
"desc": tr("Configure compass, dynamic pedals, signals, and screen borders."),
"icon": "display",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Driving Widgets & HUD"),
self._controller._hud_rows,
color="#8B5CF6",
subtitle=tr("Configure compass, dynamic pedals, signals, and screen borders."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("hud")
},
{
"title": tr("Screen Declutter & Visibility"),
"desc": tr("Toggle speed limits, alert banners, and driver monitoring icon."),
"icon": "system",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Screen Declutter & Visibility"),
self._controller._declutter_rows,
color="#8B5CF6",
subtitle=tr("Toggle speed limits, alert banners, and driver monitoring icon."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("declutter")
},
]
@@ -137,48 +110,21 @@ class AppearanceManagerView(AetherSettingsView):
"desc": tr("Configure road names, Vienna signs, and offroad routes."),
"icon": "navigate",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Navigation & Mapping"),
self._controller._nav_rows,
color="#8B5CF6",
subtitle=tr("Configure road names, Vienna signs, and offroad routes."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("nav")
},
{
"title": tr("Camera & System Startup"),
"desc": tr("Manage driver monitoring cameras, boot logos, and startup sounds."),
"icon": "vehicle",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Camera & System Startup"),
self._controller._system_rows,
color="#8B5CF6",
subtitle=tr("Manage driver monitoring cameras, boot logos, and startup sounds."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("system")
},
{
"title": tr("Advanced Metrics"),
"desc": tr("Adjust radar plots, lead vehicle info, and stop sign metrics."),
"icon": "sound",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Advanced Metrics"),
self._controller._dev_rows,
color="#8B5CF6",
subtitle=tr("Adjust radar plots, lead vehicle info, and stop sign metrics."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("dev")
},
]
@@ -515,6 +461,51 @@ class StarPilotAppearanceLayout(_SettingsPage):
panel_style=PANEL_STYLE,
)
# Register subpanels for Level 2 slide transitions
self._sub_panels["model"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._model_rows)],
header_title=tr_noop("Model & Path Visualization"),
header_subtitle=tr_noop("Customize dynamic lane paths, road edges, and colors."),
panel_style=PANEL_STYLE,
)
self._sub_panels["hud"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._hud_rows)],
header_title=tr_noop("Driving Widgets & HUD"),
header_subtitle=tr_noop("Configure compass, dynamic pedals, signals, and screen borders."),
panel_style=PANEL_STYLE,
)
self._sub_panels["declutter"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._declutter_rows)],
header_title=tr_noop("Screen Declutter & Visibility"),
header_subtitle=tr_noop("Toggle speed limits, alert banners, and driver monitoring icon."),
panel_style=PANEL_STYLE,
)
self._sub_panels["nav"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._nav_rows)],
header_title=tr_noop("Navigation & Mapping"),
header_subtitle=tr_noop("Configure road names, Vienna signs, and offroad routes."),
panel_style=PANEL_STYLE,
)
self._sub_panels["system"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._system_rows)],
header_title=tr_noop("Camera & System Startup"),
header_subtitle=tr_noop("Manage driver monitoring cameras, boot logos, and startup sounds."),
panel_style=PANEL_STYLE,
)
self._sub_panels["dev"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._dev_rows)],
header_title=tr_noop("Advanced Metrics"),
header_subtitle=tr_noop("Adjust radar plots, lead vehicle info, and stop sign metrics."),
panel_style=PANEL_STYLE,
)
self._wire_sub_panels()
# ── Theme helpers ──
def _build_theme_options(self, key: str) -> tuple[list[str], dict[str, str], str]:
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,6 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SettingRow,
SettingSection,
AetherSettingsView,
AetherCategoryTileView,
TileGrid,
HubTile,
hex_to_color,
@@ -118,48 +117,21 @@ class LongitudinalManagerView(AetherSettingsView):
"desc": tr("Configure acceleration profiles, smooth following, lane changes, and route speed control."),
"icon": "steering",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Longitudinal Tuning"),
self._controller._tune_rows,
color="#8B5CF6",
subtitle=tr("Configure acceleration profiles, smooth following, lane changes, and route speed control."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("tune")
},
{
"title": tr("Advanced Actuators"),
"desc": tr("Adjust actuator delay, EV/Truck tuning, and launch/stop speeds/rates."),
"icon": "vehicle",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Advanced Actuators"),
self._controller._advanced_rows,
color="#8B5CF6",
subtitle=tr("Adjust actuator delay, EV/Truck tuning, and launch/stop speeds/rates."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("advanced")
},
{
"title": tr("Speed Limit Controller"),
"desc": tr("Manage auto speed matching, confirmation, offsets, and source priority."),
"icon": "navigate",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Speed Limit Controller"),
self._controller._slc_rows,
color="#8B5CF6",
subtitle=tr("Manage auto speed matching, confirmation, offsets, and source priority."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("slc")
},
]
@@ -176,32 +148,14 @@ class LongitudinalManagerView(AetherSettingsView):
"desc": tr("Customize follow distance and jerk/response metrics for each personality profile."),
"icon": "system",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Driving Personalities"),
self._controller._personality_rows,
color="#8B5CF6",
subtitle=tr("Customize follow distance and jerk/response metrics for each personality profile."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("personality")
},
{
"title": tr("Daily QOL & Weather"),
"desc": tr("Configure cruise intervals, standstill behaviors, gear mapping, and weather presets."),
"icon": "sound",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Daily QOL & Weather"),
self._controller._daily_rows,
color="#8B5CF6",
subtitle=tr("Configure cruise intervals, standstill behaviors, gear mapping, and weather presets."),
panel_style=self._panel_style,
)
)
"on_click": lambda: self._controller._navigate_to("daily")
},
]
@@ -731,6 +685,43 @@ class StarPilotLongitudinalLayout(_SettingsPage):
)
self._sub_panels["adaptive_speed"] = AdaptiveSpeedView(self)
# Register subpanels for Level 2 slide transitions
self._sub_panels["tune"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._tune_rows)],
header_title=tr_noop("Longitudinal Tuning"),
header_subtitle=tr_noop("Configure acceleration profiles, smooth following, lane changes, and route speed control."),
panel_style=PANEL_STYLE,
)
self._sub_panels["advanced"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._advanced_rows)],
header_title=tr_noop("Advanced Actuators"),
header_subtitle=tr_noop("Adjust actuator delay, EV/Truck tuning, and launch/stop speeds/rates."),
panel_style=PANEL_STYLE,
)
self._sub_panels["slc"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._slc_rows)],
header_title=tr_noop("Speed Limit Controller"),
header_subtitle=tr_noop("Manage auto speed matching, confirmation, offsets, and source priority."),
panel_style=PANEL_STYLE,
)
self._sub_panels["personality"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._personality_rows)],
header_title=tr_noop("Driving Personalities"),
header_subtitle=tr_noop("Customize follow distance and jerk/response metrics for each personality profile."),
panel_style=PANEL_STYLE,
)
self._sub_panels["daily"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._daily_rows)],
header_title=tr_noop("Daily QOL & Weather"),
header_subtitle=tr_noop("Configure cruise intervals, standstill behaviors, gear mapping, and weather presets."),
panel_style=PANEL_STYLE,
)
self._wire_sub_panels()
def _get_priority_value(self) -> str:
@@ -831,42 +822,41 @@ class StarPilotLongitudinalLayout(_SettingsPage):
return (-150, 150) if self._is_metric() else (-99, 99)
def _show_slc_offsets_category(self):
gui_app.push_widget(
AetherCategoryTileView(
self,
tr("SLC Offsets"),
self._slc_offset_rows,
color="#8B5CF6",
subtitle=tr("Per-limit speed adjustments for the Speed Limit Controller."),
panel_style=PANEL_STYLE,
)
self._sub_panels["slc_offsets"] = AetherSettingsView(
self,
[SettingSection(title="", rows=self._slc_offset_rows)],
header_title=tr_noop("SLC Offsets"),
header_subtitle=tr_noop("Per-limit speed adjustments for the Speed Limit Controller."),
panel_style=PANEL_STYLE,
)
self._wire_sub_panels()
self._navigate_to("slc_offsets")
def _show_personality_profile_category(self, profile: str):
rows = self._build_personality_profile_rows(profile)
gui_app.push_widget(
AetherCategoryTileView(
self,
tr(f"{profile} Profile"),
rows,
color="#8B5CF6",
subtitle=tr("Customize follow distance and smoothness for this driving personality."),
panel_style=PANEL_STYLE,
)
panel_name = f"profile_{profile.lower()}"
self._sub_panels[panel_name] = AetherSettingsView(
self,
[SettingSection(title="", rows=rows)],
header_title=tr_noop(f"{profile} Profile"),
header_subtitle=tr_noop("Customize follow distance and smoothness for this driving personality."),
panel_style=PANEL_STYLE,
)
self._wire_sub_panels()
self._navigate_to(panel_name)
def _show_weather_offsets_category(self, suffix: str, title: str):
rows = self._build_weather_offsets_rows(suffix)
gui_app.push_widget(
AetherCategoryTileView(
self,
tr(title),
rows,
color="#8B5CF6",
subtitle=tr("Adjust driving parameters for this weather condition."),
panel_style=PANEL_STYLE,
)
panel_name = f"weather_{suffix.lower()}"
self._sub_panels[panel_name] = AetherSettingsView(
self,
[SettingSection(title="", rows=rows)],
header_title=tr_noop(title),
header_subtitle=tr_noop("Adjust driving parameters for this weather condition."),
panel_style=PANEL_STYLE,
)
self._wire_sub_panels()
self._navigate_to(panel_name)
def _build_personality_profile_rows(self, profile: str) -> list[SettingRow]:
follow_min = 1.0 if profile == "Traffic" else 0.5
@@ -7,7 +7,7 @@ from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.lib.application import MousePos, gui_app, FontWeight
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType, StarPilotPanelInfo, FrameCachedParams
from openpilot.selfdrive.ui.layouts.settings.starpilot.sounds import StarPilotSoundsLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.driving_model import StarPilotDrivingModelLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.longitudinal import StarPilotLongitudinalLayout
@@ -17,7 +17,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.system_settings import St
from openpilot.selfdrive.ui.layouts.settings.starpilot.appearance import StarPilotAppearanceLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.vehicle import StarPilotVehicleSettingsLayout
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, SPACING, BreadcrumbController, AETHER_LIST_METRICS, draw_rounded_fill, draw_rounded_stroke
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, SPACING, BreadcrumbController, AETHER_LIST_METRICS, draw_rounded_fill, draw_rounded_stroke, AetherTransitionManager
class StarPilotLayout(Widget):
CATEGORIES = [
@@ -55,7 +55,7 @@ class StarPilotLayout(Widget):
def __init__(self):
super().__init__()
self._params = Params()
self._params = FrameCachedParams()
self._current_panel = StarPilotPanelType.MAIN
self._current_category_idx: int | None = None
@@ -92,6 +92,23 @@ class StarPilotLayout(Widget):
self._breadcrumbs = BreadcrumbController()
self._main_grid = TileGrid(columns=None, padding=SPACING.tile_gap)
self._rebuild_grid()
self._transition_manager = AetherTransitionManager()
def _make_render_fn(self, panel_type: StarPilotPanelType) -> Callable[[rl.Rectangle], None]:
if panel_type == StarPilotPanelType.MAIN:
def render_main(rect: rl.Rectangle):
metrics = AETHER_LIST_METRICS
shell_w = min(rect.width - metrics.outer_margin_x * 2, metrics.max_content_width)
shell_x = rect.x + (rect.width - shell_w) / 2
grid_rect = rl.Rectangle(
shell_x, rect.y + metrics.outer_margin_y,
shell_w, rect.height - metrics.outer_margin_y * 2
)
self._main_grid.render(grid_rect)
return render_main
else:
panel = self._panels[panel_type]
return lambda r: panel.instance.render(r) if panel.instance else None
def set_depth_callback(self, callback: Callable):
self._depth_callback = callback
@@ -164,6 +181,10 @@ class StarPilotLayout(Widget):
panel.set_navigate_callback(self._push_sub_panel)
def _rebuild_grid(self):
state = (self._current_category_idx,)
if getattr(self, "_last_grid_state", None) == state:
return
self._last_grid_state = state
self._main_grid.clear()
panel_type_map = {
@@ -226,6 +247,14 @@ class StarPilotLayout(Widget):
def _set_current_panel(self, panel_type: StarPilotPanelType):
if panel_type != self._current_panel:
old_panel = self._current_panel
direction = -1 if panel_type == StarPilotPanelType.MAIN else 1
self._transition_manager.start(
self._make_render_fn(old_panel),
self._make_render_fn(panel_type),
direction
)
if self._current_panel != StarPilotPanelType.MAIN:
old = self._panels[self._current_panel].instance
old.hide_event()
@@ -271,19 +300,29 @@ class StarPilotLayout(Widget):
crumb_rect = rl.Rectangle(glass_rect.x, glass_rect.y, glass_rect.width, glass_rect.height)
self._breadcrumbs.draw(crumb_rect)
# Update transitions
self._transition_manager.update(rl.get_frame_time())
# 4. Render active content panel
if self._current_panel == StarPilotPanelType.MAIN:
grid_rect = rl.Rectangle(shell_x, content_rect.y + AETHER_LIST_METRICS.outer_margin_y, shell_w, content_rect.height - AETHER_LIST_METRICS.outer_margin_y * 2)
self._main_grid.render(grid_rect)
if self._transition_manager.is_animating():
self._transition_manager.render(content_rect)
else:
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(content_rect)
if self._current_panel == StarPilotPanelType.MAIN:
grid_rect = rl.Rectangle(shell_x, content_rect.y + AETHER_LIST_METRICS.outer_margin_y, shell_w, content_rect.height - AETHER_LIST_METRICS.outer_margin_y * 2)
self._main_grid.render(grid_rect)
else:
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(content_rect)
def _handle_mouse_press(self, mouse_pos: MousePos):
if self._transition_manager.is_animating():
return
self._breadcrumbs.init_interaction(mouse_pos)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._transition_manager.is_animating():
return
action = self._breadcrumbs.finish_interaction(mouse_pos)
if action:
self._breadcrumbs.handle_click(action)
@@ -10,8 +10,73 @@ from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, ToggleTile, ValueTile, SliderTile, SPACING, AetherSliderDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import TileGrid, HubTile, ToggleTile, ValueTile, SliderTile, SPACING, AetherSliderDialog, AetherTransitionManager
from openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel import SectionedTileLayout, TileSection
import time
class FrameCachedParams:
def __init__(self):
self._params = Params()
self._cache = {}
self._last_frame_time = 0.0
def _check_clear_cache(self):
now = time.monotonic()
if now != self._last_frame_time:
self._cache.clear()
self._last_frame_time = now
def get(self, key, **kwargs):
self._check_clear_cache()
cache_key = (key, "get", tuple(kwargs.items()))
if cache_key not in self._cache:
self._cache[cache_key] = self._params.get(key, **kwargs)
return self._cache[cache_key]
def get_bool(self, key, **kwargs):
self._check_clear_cache()
cache_key = (key, "get_bool", tuple(kwargs.items()))
if cache_key not in self._cache:
self._cache[cache_key] = self._params.get_bool(key, **kwargs)
return self._cache[cache_key]
def get_int(self, key, **kwargs):
self._check_clear_cache()
cache_key = (key, "get_int", tuple(kwargs.items()))
if cache_key not in self._cache:
self._cache[cache_key] = self._params.get_int(key, **kwargs)
return self._cache[cache_key]
def get_float(self, key, **kwargs):
self._check_clear_cache()
cache_key = (key, "get_float", tuple(kwargs.items()))
if cache_key not in self._cache:
self._cache[cache_key] = self._params.get_float(key, **kwargs)
return self._cache[cache_key]
def put(self, key, val, **kwargs):
self._params.put(key, val, **kwargs)
self._cache.clear()
def put_bool(self, key, val, **kwargs):
self._params.put_bool(key, val, **kwargs)
self._cache.clear()
def put_int(self, key, val, **kwargs):
self._params.put_int(key, val, **kwargs)
self._cache.clear()
def put_float(self, key, val, **kwargs):
self._params.put_float(key, val, **kwargs)
self._cache.clear()
def remove(self, key):
self._params.remove(key)
self._cache.clear()
def __getattr__(self, name):
return getattr(self._params, name)
class StarPilotPanelType(IntEnum):
@@ -41,7 +106,7 @@ class StarPilotPanel(Widget):
def __init__(self):
super().__init__()
self._params_memory = Params(memory=True)
self._params = Params()
self._params = FrameCachedParams()
self._navigate_callback: Callable | None = None
self._back_callback: Callable | None = None
self._current_sub_panel = ""
@@ -51,6 +116,7 @@ class StarPilotPanel(Widget):
self._sectioned_grid = None
self.CATEGORIES = []
self.SECTIONS = []
self._transition_manager = AetherTransitionManager()
def set_navigate_callback(self, callback: Callable):
self._navigate_callback = callback
@@ -159,17 +225,50 @@ class StarPilotPanel(Widget):
if tile is not None:
self._tile_grid.add_tile(tile)
def _make_render_fn(self, sub_panel: str) -> Callable[[rl.Rectangle], None]:
if sub_panel and sub_panel in self._sub_panels:
panel = self._sub_panels[sub_panel]
return lambda r: panel.render(r)
else:
def render_base(rect: rl.Rectangle):
if self.SECTIONS and self._sectioned_grid:
self._sectioned_grid.render(rect)
elif self.CATEGORIES and self._tile_grid:
self._tile_grid.render(rect)
elif self._scroller:
self._scroller.render(rect)
return render_base
def _navigate_to(self, sub_panel: str):
self._current_sub_panel = sub_panel
if self._navigate_callback:
self._navigate_callback(sub_panel)
if sub_panel != self._current_sub_panel:
old_sub = self._current_sub_panel
self._transition_manager.start(
self._make_render_fn(old_sub),
self._make_render_fn(sub_panel),
1
)
self._current_sub_panel = sub_panel
if self._navigate_callback:
self._navigate_callback(sub_panel)
def _go_back(self):
self._current_sub_panel = ""
if self._back_callback:
self._back_callback()
if self._current_sub_panel:
old_sub = self._current_sub_panel
self._transition_manager.start(
self._make_render_fn(old_sub),
self._make_render_fn(""),
-1
)
self._current_sub_panel = ""
if self._back_callback:
self._back_callback()
def _render(self, rect: rl.Rectangle):
self._transition_manager.update(rl.get_frame_time())
if self._transition_manager.is_animating():
self._transition_manager.render(rect)
return
if self._current_sub_panel and self._current_sub_panel in self._sub_panels:
self._sub_panels[self._current_sub_panel].render(rect)
elif self.SECTIONS and self._sectioned_grid:
@@ -179,9 +278,27 @@ class StarPilotPanel(Widget):
elif self._scroller:
self._scroller.render(rect)
def _handle_mouse_press(self, mouse_pos):
if self._transition_manager.is_animating():
return
super()._handle_mouse_press(mouse_pos)
def _handle_mouse_release(self, mouse_pos):
if self._transition_manager.is_animating():
return
super()._handle_mouse_release(mouse_pos)
def _handle_mouse_event(self, mouse_event):
if self._transition_manager.is_animating():
return
super()._handle_mouse_event(mouse_event)
def show_event(self):
super().show_event()
self._rebuild_grid()
if self.SECTIONS and self._sectioned_grid is None:
self._rebuild_grid()
elif self.CATEGORIES and self._tile_grid is None:
self._rebuild_grid()
if self._current_sub_panel and self._current_sub_panel in self._sub_panels:
self._sub_panels[self._current_sub_panel].show_event()
elif self.SECTIONS and self._sectioned_grid:
+13 -13
View File
@@ -446,7 +446,7 @@ class TestAethergridContracts(unittest.TestCase):
grid.render(mod.rl.Rectangle(0, 50, 500, 300))
self.assertTrue(spy.rects)
self.assertEqual(spy.rects[0].y, 50)
self.assertEqual(spy.rects[0].y, 130)
self.assertEqual(spy.rects[0].x, 0)
def test_disabled_tiles_hud_mode_rendering(self):
@@ -559,28 +559,28 @@ class TestAethergridContracts(unittest.TestCase):
view = mod.AetherCategoryTileView(controller_mock, "Category Title", rows, color="#FF0000", subtitle="Category Description")
self.assertEqual(len(view._row_to_tile_map), 3)
self.assertIsInstance(view._row_to_tile_map["toggle_row"], mod.RowToggleTile)
self.assertIsInstance(view._row_to_tile_map["value_row"], mod.RowPanelTile)
self.assertIsInstance(view._row_to_tile_map["action_row"], mod.RowPanelTile)
self.assertEqual(len(view._sections), 1)
self.assertEqual(len(view._sections[0].rows), 3)
view._update_visible_tiles()
self.assertEqual(len(view._tile_grid.tiles), 3)
visible = view._visible_rows(view._sections[0])
self.assertEqual(len(visible), 3)
toggle_visible = False
view._update_visible_tiles()
self.assertEqual(len(view._tile_grid.tiles), 2)
self.assertNotIn(view._row_to_tile_map["toggle_row"], view._tile_grid.tiles)
visible = view._visible_rows(view._sections[0])
self.assertEqual(len(visible), 2)
self.assertNotIn(rows[0], visible)
view._back_btn_rect = mod.rl.Rectangle(196, 56, 68, 68)
view._slide_progress = 1.0 # fully slide-in
view.set_rect(mod.rl.Rectangle(0, 0, 1920, 1080))
self.assertEqual(view._target_at(mod.rl.Vector2(200, 60)), "static:back")
self.assertNotEqual(view._target_at(mod.rl.Vector2(0, 0)), "static:back")
self.assertEqual(view._target_at(mod.rl.Vector2(200, 60)), mod.BACK_BTN)
self.assertEqual(view._target_at(mod.rl.Vector2(1000, 60)), "__dismiss__")
app_mod = sys.modules["openpilot.system.ui.lib.application"]
app_mod.gui_app.pop_widget = MagicMock()
view._activate_target("static:back")
view._activate_target(mod.BACK_BTN)
app_mod.gui_app.pop_widget.assert_called_once()