BigUI WIP: Standarize Breadcrumbs

This commit is contained in:
firestarsdog
2026-06-25 22:48:37 -04:00
parent 1ce49be89b
commit 44255d67c2
10 changed files with 471 additions and 343 deletions
@@ -227,7 +227,7 @@ class AetherListMetrics:
panel_padding_x: int = 16
panel_padding_top: int = 28
panel_padding_bottom: int = 22
header_height: int = 244
header_height: int = 0
section_gap: int = 28
section_header_height: int = 34
section_header_gap: int = 12
@@ -266,7 +266,7 @@ class AetherListFrame:
AETHER_LIST_METRICS = AetherListMetrics()
AETHER_COMPACT_ROW_HEIGHT = AETHER_LIST_METRICS.utility_row_height
COMPACT_PANEL_METRICS = replace(AETHER_LIST_METRICS, header_height=125)
COMPACT_PANEL_METRICS = replace(AETHER_LIST_METRICS, header_height=0)
@dataclass(frozen=True, slots=True)
@@ -338,6 +338,26 @@ def _point_hits(mouse_pos: MousePos, rect: rl.Rectangle, parent_rect: rl.Rectang
return hit.width > 0 and hit.height > 0 and rl.check_collision_point_rec(mouse_pos, hit)
def wrap_text(font: rl.Font, text: str, max_width: float, font_size: float, max_lines: int = 2) -> list[str]:
spacing = font_size * 0.15
words = text.split()
lines: list[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip() if current else word
if measure_text_cached(font, candidate, int(font_size), spacing=spacing).x <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = word
if len(lines) >= max_lines:
break
if current and len(lines) < max_lines:
lines.append(current)
return lines if lines else [text]
def build_list_panel_frame(rect: rl.Rectangle, metrics: AetherListMetrics = AETHER_LIST_METRICS) -> AetherListFrame:
shell_w = min(rect.width - metrics.outer_margin_x * 2, metrics.max_content_width)
shell_x = rect.x + (rect.width - shell_w) / 2
@@ -864,40 +884,34 @@ PRESSED_BREADCRUMB: str | None = None
def get_breadcrumbs_path() -> list[tuple[str, str]]:
import openpilot.selfdrive.ui.layouts.settings.starpilot.main_panel as main_panel
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType
layout = getattr(main_panel.StarPilotLayout, "active_instance", None)
path = [("HOME", "action:home")]
if not layout:
return path
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType
pushed_widgets = gui_app._nav_stack[1:]
# 1. Add Category if selected
cat_title = ""
is_folder = False
if layout._current_category_idx is not None:
cat = layout.CATEGORIES[layout._current_category_idx]
cat_title = cat["title"].upper()
is_folder = "buttons" in cat
if is_folder:
if layout._current_panel != StarPilotPanelType.MAIN:
path.append((cat_title, "action:category"))
elif pushed_widgets:
path.append((cat_title, "action:category"))
else:
if pushed_widgets:
path.append((cat_title, "action:category"))
if layout._current_panel != StarPilotPanelType.MAIN and pushed_widgets:
path.append((cat_title, "action:category"))
# 2. Add Sub-panel if active
if layout._current_panel != StarPilotPanelType.MAIN:
panel_info = layout._panels[layout._current_panel]
if panel_info.name:
panel_title = panel_info.name.upper()
if is_folder or layout._current_category_idx is None:
panel_title = panel_info.name.upper()
path.append((panel_title, "action:panel"))
for i, widget in enumerate(pushed_widgets[:-1]):
# 3. Add any pushed widgets from nav stack
for i, widget in enumerate(pushed_widgets):
if hasattr(widget, '_header_title') and widget._header_title:
path.append((widget._header_title.upper(), f"action:nav_stack:{i+1}"))
@@ -935,45 +949,152 @@ def handle_breadcrumb_click(target: str):
target_idx = int(target.split(":")[-1])
while len(gui_app._nav_stack) > target_idx + 1:
gui_app.pop_widget()
elif target == "action:breadcrumb_history":
full_path = get_breadcrumbs_path()
middle_steps = full_path[1:-1]
options = [text for text, action in middle_steps]
action_map = {text: action for text, action in middle_steps}
def on_select(res):
if res == DialogResult.CONFIRM and dialog.selection:
chosen_action = action_map.get(dialog.selection)
if chosen_action:
handle_breadcrumb_click(chosen_action)
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
dialog = MultiOptionDialog(tr("Navigation History"), options, "", callback=on_select)
gui_app.push_widget(dialog)
def draw_breadcrumbs(start_pos: rl.Vector2, max_width: float) -> None:
def draw_breadcrumbs(rect: rl.Rectangle) -> None:
"""Draw the breadcrumb trail centered inside `rect`.
All glyphs — past-step labels, chevrons, and the active-step label — share
a single vertical midline so nothing looks dropped or misaligned.
"""
BREADCRUMB_RECTS.clear()
path = get_breadcrumbs_path()
if not path:
return
font = gui_app.font(FontWeight.SEMI_BOLD)
font_size = 18
color_normal = rl.Color(160, 170, 185, 255)
color_hover = rl.Color(236, 242, 250, 255)
color_pressed = rl.Color(255, 255, 255, 180)
color_sep = rl.Color(92, 116, 151, 150)
current_x = start_pos.x
for i, (text, action) in enumerate(path):
return
display_path = list(path)
if len(path) > 3:
display_path = [path[0], ("...", "action:breadcrumb_history"), path[-1]]
# Sizes
ACTIVE_SIZE = 26 # font size for current/last step
PAST_SIZE = 19 # font size for ancestor steps
CHEVRON_SIZE = 16 # visual half-height of chevron arms
CHEVRON_W = 14 # horizontal extent of chevron
GAP = 14 # spacing between every element
center_y = rect.y + rect.height / 2 # single shared vertical center
# ── measure total row width so we can clip/left-align ─────────────────────
# (We just render left-to-right; no centering needed for breadcrumbs)
color_sep = rl.Color(80, 90, 115, 160)
mouse_pos = gui_app.last_mouse_event.pos
current_x = rect.x + 20 # left inset inside the pill
for i, (text, action) in enumerate(display_path):
is_last = (i == len(display_path) - 1)
is_overflow = (action == "action:breadcrumb_history")
pressed = PRESSED_BREADCRUMB == action
if is_overflow:
# ── glowing "..." capsule ────────────────────────────────────────────
capsule_w, capsule_h = 50, 26
cap_rect = rl.Rectangle(
current_x,
center_y - capsule_h / 2,
capsule_w,
capsule_h,
)
hovered = _point_hits(mouse_pos, cap_rect, None, pad_x=4, pad_y=6)
BREADCRUMB_RECTS[action] = cap_rect
if pressed:
fill, outline, glow, dots_c = (
rl.Color(45, 30, 75, 230),
rl.Color(167, 139, 250, 255),
rl.Color(167, 139, 250, 90),
rl.Color(255, 255, 255, 255),
)
elif hovered:
fill, outline, glow, dots_c = (
rl.Color(30, 20, 50, 200),
rl.Color(139, 92, 246, 200),
rl.Color(139, 92, 246, 60),
rl.Color(255, 255, 255, 255),
)
else:
fill, outline, glow, dots_c = (
rl.Color(20, 15, 30, 150),
rl.Color(120, 110, 220, 80),
rl.Color(120, 110, 220, 20),
rl.Color(190, 180, 220, 180),
)
# outer glow halo
rl.draw_rectangle_rounded_lines_ex(
rl.Rectangle(cap_rect.x - 2, cap_rect.y - 2, cap_rect.width + 4, cap_rect.height + 4),
1.0, 16, 1.5, glow,
)
rl.draw_rectangle_rounded(cap_rect, 1.0, 16, fill)
rl.draw_rectangle_rounded_lines_ex(cap_rect, 1.0, 16, 1.0, outline)
font_dots = gui_app.font(FontWeight.BOLD)
dots_w = measure_text_cached(font_dots, "...", 18).x
rl.draw_text_ex(
font_dots, "...",
rl.Vector2(cap_rect.x + (cap_rect.width - dots_w) / 2, center_y - 10),
18, 0, dots_c,
)
current_x += capsule_w + GAP
else:
# ── normal breadcrumb label ──────────────────────────────────────────
if is_last:
font = gui_app.font(FontWeight.BOLD)
font_size = ACTIVE_SIZE
c_normal = rl.Color(255, 255, 255, 255)
c_hover = rl.Color(255, 255, 255, 255)
c_pressed = rl.Color(200, 200, 200, 255)
else:
font = gui_app.font(FontWeight.MEDIUM)
font_size = PAST_SIZE
c_normal = rl.Color(110, 105, 130, 255)
c_hover = rl.Color(160, 155, 185, 255)
c_pressed = rl.Color(190, 185, 215, 255)
text_w = measure_text_cached(font, text, font_size).x
rect = rl.Rectangle(current_x - 8, start_pos.y - 4, text_w + 16, font_size + 8)
mouse_pos = gui_app.last_mouse_event.pos
hovered = _point_hits(mouse_pos, rect, None, pad_x=0, pad_y=0)
pressed = PRESSED_BREADCRUMB == action
color = color_pressed if pressed else (color_hover if hovered else color_normal)
BREADCRUMB_RECTS[action] = rect
if hovered:
rl.draw_rectangle_rounded(rect, 0.3, 8, rl.Color(255, 255, 255, 15))
rl.draw_text_ex(font, text, rl.Vector2(current_x, start_pos.y + 2), font_size, 0, color)
current_x += text_w + 16
if i < len(path) - 1:
sep_w = measure_text_cached(font, "/", font_size).x
rl.draw_text_ex(font, "/", rl.Vector2(current_x, start_pos.y + 2), font_size, 0, color_sep)
current_x += sep_w + 16
# hit rect: generous padding for touch
hit_rect = rl.Rectangle(current_x - 6, center_y - 20, text_w + 12, 40)
hovered = _point_hits(mouse_pos, hit_rect, None, pad_x=0, pad_y=0)
BREADCRUMB_RECTS[action] = hit_rect
color = c_pressed if pressed else (c_hover if hovered else c_normal)
if hovered and not is_last:
rl.draw_rectangle_rounded(hit_rect, 0.4, 8, rl.Color(255, 255, 255, 12))
# draw text centered on the shared midline
text_y = center_y - font_size / 2
rl.draw_text_ex(font, text, rl.Vector2(current_x, text_y), font_size, 0, color)
current_x += text_w + GAP
# ── chevron separator ──────────────────────────────────────────────────
if i < len(display_path) - 1:
chev_rect = rl.Rectangle(
current_x,
center_y - CHEVRON_SIZE / 2,
CHEVRON_W,
CHEVRON_SIZE,
)
draw_chevron_icon(chev_rect, color_sep, thickness=2.0, direction="right")
current_x += CHEVRON_W + GAP
PANEL_HEADER_TITLE_Y: int = 34
@@ -994,13 +1115,7 @@ def draw_settings_panel_header(header_rect: rl.Rectangle, title: str, subtitle:
subtitle_color: rl.Color = AetherListColors.SUBTEXT,
title_weight: FontWeight = PANEL_HEADER_TITLE_FONT,
subtitle_weight: FontWeight = PANEL_HEADER_SUBTITLE_FONT):
draw_breadcrumbs(rl.Vector2(header_rect.x, header_rect.y + 2), header_rect.width)
title_rect = rl.Rectangle(header_rect.x, header_rect.y + PANEL_HEADER_TITLE_Y, header_rect.width * max_title_width, title_size + 2)
gui_label(title_rect, title, title_size, title_color, title_weight)
if subtitle:
subtitle_rect = rl.Rectangle(header_rect.x, header_rect.y + PANEL_HEADER_SUBTITLE_Y, header_rect.width * max_subtitle_width, subtitle_size + 4)
gui_label(subtitle_rect, subtitle, subtitle_size, subtitle_color, subtitle_weight)
pass
@@ -2625,6 +2740,27 @@ class AetherSettingsView(PanelManagerView):
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
y = rect.y + self._scroll_offset
if self._has_header:
title = tr(self._header_title) if self._header_title else ""
subtitle = tr(self._header_subtitle) if self._header_subtitle else ""
col_w = (width - self.COLUMN_GAP) / 2 if self._uses_two_columns(width) else width
title_font = gui_app.font(FontWeight.SEMI_BOLD)
title_size = 32
rl.draw_text_ex(title_font, title, rl.Vector2(rect.x + 8, y), title_size, 0, AetherListColors.HEADER)
y += title_size + 8
if subtitle:
desc_font = gui_app.font(FontWeight.NORMAL)
desc_size = 18
desc_lines = wrap_text(desc_font, subtitle, col_w - 16, desc_size, max_lines=4)
for line in desc_lines:
rl.draw_text_ex(desc_font, line, rl.Vector2(rect.x + 8, y), desc_size, 0, AetherListColors.SUBTEXT)
y += desc_size + 4
y += 12
if self._tab_defs:
y = self._draw_tabs(y, rect.x, width)
active = self._active_sections()
@@ -2668,17 +2804,11 @@ class AetherSettingsView(PanelManagerView):
draw_list_group_shell(right_group, style=self._panel_style)
for j, row in enumerate(visible_rows):
self._draw_row(rl.Rectangle(rect.x, y + j * section.row_height, col_w, section.row_height), row, is_last=(j == len(visible_rows) - 1))
row_rect = rl.Rectangle(rect.x, y + j * section.row_height, col_w, section.row_height)
row.set_is_last(j == len(visible_rows) - 1)
row.set_parent_rect(self._scroll_rect)
row.render(row_rect)
for j, row in enumerate(right_rows):
self._draw_row(rl.Rectangle(rect.x + col_w + self.COLUMN_GAP, y + j * right_section.row_height, col_w, right_section.row_height), row, is_last=(j == len(right_rows) - 1))
y += group_h
if self._has_subsequent_visible(i + 2, active):
y += SECTION_GAP
i += 2
else:
y = self._draw_section(y, rect.x, width, section, visible_rows)
if self._has_subsequent_visible(i + 1, active):
y += SECTION_GAP
i += 1
@@ -2909,46 +3039,31 @@ class AetherCategoryTileView(AetherSettingsView):
)
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"
# Check breadcrumb hits first (they're drawn in the header)
crumb_target = resolve_interactive_target(mouse_pos, BREADCRUMB_RECTS, None, pad_x=8, pad_y=8)
if crumb_target:
return f"breadcrumb:{crumb_target}"
return super()._target_at(mouse_pos)
def _activate_target(self, target_id: str | None):
if target_id == "static:back":
gui_app.pop_widget()
if target_id and target_id.startswith("breadcrumb:"):
action = target_id[len("breadcrumb:"):]
if action == "action:panel": # topmost panel step = pop this dialog
gui_app.pop_widget()
else:
handle_breadcrumb_click(action)
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)
# Draw the same glass breadcrumb pill that appears in the main nav bar
pill_h = 52
pill_rect = rl.Rectangle(rect.x, rect.y + (rect.height - pill_h) / 2, rect.width, pill_h)
_draw_rounded_fill(pill_rect, rl.Color(18, 16, 24, 200), radius_px=14)
_draw_rounded_stroke(pill_rect, rl.Color(255, 255, 255, 22), radius_px=14)
# Breadcrumbs fill left portion; leave ~20px right inset
crumb_rect = rl.Rectangle(pill_rect.x, pill_rect.y, pill_rect.width - 20, pill_rect.height)
draw_breadcrumbs(crumb_rect)
# ── AetherSubMenuTileView — category panel containing navigation HubTiles ──
@@ -3063,46 +3178,31 @@ class AetherSubMenuTileView(AetherSettingsView):
)
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"
# Check breadcrumb hits first (they're drawn in the header)
crumb_target = resolve_interactive_target(mouse_pos, BREADCRUMB_RECTS, None, pad_x=8, pad_y=8)
if crumb_target:
return f"breadcrumb:{crumb_target}"
return super()._target_at(mouse_pos)
def _activate_target(self, target_id: str | None):
if target_id == "static:back":
gui_app.pop_widget()
if target_id and target_id.startswith("breadcrumb:"):
action = target_id[len("breadcrumb:"):]
if action == "action:panel": # topmost panel step = pop this dialog
gui_app.pop_widget()
else:
handle_breadcrumb_click(action)
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)
# Draw the same glass breadcrumb pill that appears in the main nav bar
pill_h = 52
pill_rect = rl.Rectangle(rect.x, rect.y + (rect.height - pill_h) / 2, rect.width, pill_h)
_draw_rounded_fill(pill_rect, rl.Color(18, 16, 24, 200), radius_px=14)
_draw_rounded_stroke(pill_rect, rl.Color(255, 255, 255, 22), radius_px=14)
# Breadcrumbs fill left portion; leave ~20px right inset
crumb_rect = rl.Rectangle(pill_rect.x, pill_rect.y, pill_rect.width - 20, pill_rect.height)
draw_breadcrumbs(crumb_rect)
class AetherTile(Widget):
@@ -3285,23 +3385,7 @@ class AetherTile(Widget):
return face, ty
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.split()
lines: list[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip() if current else word
if measure_text_cached(font, candidate, int(font_size), spacing=spacing).x <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = word
if len(lines) >= max_lines:
break
if current and len(lines) < max_lines:
lines.append(current)
return lines if lines else [text]
return wrap_text(font, text, max_width, font_size, max_lines)
def _draw_signal_edge(self, face: rl.Rectangle, color: rl.Color, width: int = 2, alpha: int = 58):
snapped_face = _snap_rect(face)
@@ -5243,6 +5327,10 @@ class TileGrid(Widget):
cols = self.get_effective_column_count(width, count)
col_w = (width - (self._gap * (cols - 1))) / cols
h = col_w
if self.min_tile_height is not None:
h = max(self.min_tile_height, h)
if self.max_tile_height is not None:
h = min(self.max_tile_height, h)
else:
if self._tile_height is not None:
h = self._tile_height
@@ -5295,6 +5383,11 @@ class TileGrid(Widget):
if self.force_square:
uniform_tile_w = (rect.width - (self._gap * (cols - 1))) / cols
tile_h = uniform_tile_w
if self.min_tile_height is not None:
tile_h = max(self.min_tile_height, tile_h)
if self.max_tile_height is not None:
tile_h = min(self.max_tile_height, tile_h)
uniform_tile_w = tile_h
else:
if self._tile_height is not None:
tile_h = self._tile_height
@@ -5313,7 +5406,8 @@ class TileGrid(Widget):
items_in_row = min(cols, remaining)
if self.force_square or self._uniform_width:
row_tile_w = uniform_tile_w
row_x = rect.x
row_width = row_tile_w * items_in_row + self._gap * (items_in_row - 1)
row_x = rect.x + (rect.width - row_width) / 2
else:
row_tile_w = (rect.width - (self._gap * (items_in_row - 1))) / items_in_row
row_x = rect.x
@@ -208,24 +208,9 @@ class AppearanceManagerView(AetherSettingsView):
margin_x = 12.0
margin_top = 16.0
margin_bottom = 16.0
header_h = 125.0
gap_after_header = 16.0
# Draw the header at the top of rect:
header_rect = rl.Rectangle(rect.x + margin_x, rect.y + margin_top, rect.width - margin_x * 2, header_h)
self._draw_header(header_rect)
# Draw a nice separator line under the header:
divider_y = rect.y + margin_top + header_h + 8.0
rl.draw_line_ex(
rl.Vector2(rect.x + margin_x, divider_y),
rl.Vector2(rect.x + rect.width - margin_x, divider_y),
2.0,
rl.Color(255, 255, 255, 16)
)
# Compute remaining scroll rect:
grid_top = divider_y + gap_after_header
# Compute remaining scroll rect starting directly from the top
grid_top = rect.y + margin_top
grid_h = rect.y + rect.height - grid_top - margin_bottom
self._scroll_rect = rl.Rectangle(rect.x + margin_x, grid_top, rect.width - margin_x * 2, grid_h)
@@ -58,6 +58,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
init_list_panel,
draw_interactive_rect,
resolve_interactive_target,
wrap_text,
)
@@ -70,7 +71,7 @@ ROW_RADIUS = AETHER_LIST_METRICS.row_radius
ACTION_WIDTH = AETHER_LIST_METRICS.action_width
BUTTON_HEIGHT = AETHER_LIST_METRICS.header_button_height
FADE_HEIGHT = AETHER_LIST_METRICS.fade_height
DRIVING_MODEL_METRICS = replace(AETHER_LIST_METRICS, header_height=244)
DRIVING_MODEL_METRICS = replace(AETHER_LIST_METRICS, header_height=0)
CONFIRM_TIMEOUT_SECONDS = 3.0
TRANSITION_SECONDS = 0.24
PANEL_STYLE = DEFAULT_PANEL_STYLE
@@ -265,6 +266,10 @@ class DrivingModelManagerView(AetherInteractiveMixin, Widget):
self._shell_rect = frame.shell
self._scroll_rect = scroll_rect
self._primary_header_button.set_parent_rect(scroll_rect)
self._secondary_header_button.set_parent_rect(scroll_rect)
self._random_model_button.set_parent_rect(scroll_rect)
self._draw_header(frame.header)
self._content_height = self._measure_content_height(content_width)
self._scroll_panel.set_enabled(lambda: not self._controller._is_download_active())
@@ -280,28 +285,22 @@ class DrivingModelManagerView(AetherInteractiveMixin, Widget):
draw_list_scroll_fades(scroll_rect, self._content_height, self._scroll_offset, AetherListColors.PANEL_BG, fade_height=FADE_HEIGHT)
def _draw_header(self, rect: rl.Rectangle):
draw_settings_panel_header(rect, tr("Driving Models"), self._controller.header_description_text(), subtitle_size=24)
pass
current_label_rect = rl.Rectangle(rect.x, rect.y + 130, 150, 22)
gui_label(current_label_rect, tr("Current Model"), 20, AetherListColors.MUTED, FontWeight.MEDIUM)
current_value_rect = rl.Rectangle(rect.x + 150, rect.y + 128, rect.width * 0.44, 24)
gui_label(current_value_rect, self._controller._current_model_name, 22, AetherListColors.HEADER, FontWeight.MEDIUM)
right_panel_w = min(390, rect.width * 0.35)
btn_gap = 10
stack_y = rect.y + 8
right_x = rect.x + rect.width - right_panel_w
primary_rect = rl.Rectangle(right_x, stack_y, right_panel_w, BUTTON_HEIGHT)
secondary_rect = rl.Rectangle(right_x, stack_y + BUTTON_HEIGHT + btn_gap, right_panel_w, BUTTON_HEIGHT)
random_rect = rl.Rectangle(right_x, stack_y + (BUTTON_HEIGHT + btn_gap) * 2, right_panel_w, BUTTON_HEIGHT)
def _draw_relocated_header(self, x: float, y: float, width: float):
# Buttons placed horizontally
btn_gap = 12.0
btn_w = (width - 16.0 - btn_gap * 2) / 3.0
primary_rect = rl.Rectangle(x + 8, y, btn_w, BUTTON_HEIGHT)
secondary_rect = rl.Rectangle(x + 8 + btn_w + btn_gap, y, btn_w, BUTTON_HEIGHT)
random_rect = rl.Rectangle(x + 8 + (btn_w + btn_gap) * 2, y, btn_w, BUTTON_HEIGHT)
self._primary_header_button.render(primary_rect)
self._secondary_header_button.render(secondary_rect)
self._random_model_button.render(random_rect)
# LED indicator drawn on top of the randomizer button
# LED indicator
led_x = int(random_rect.x + random_rect.width - 26)
led_y = int(random_rect.y + random_rect.height / 2)
randomizer_on = self._controller._params.get_bool("ModelRandomizer")
@@ -309,9 +308,10 @@ class DrivingModelManagerView(AetherInteractiveMixin, Widget):
def _measure_content_height(self, width: float) -> float:
sections = self._build_sections(width)
RELOCATED_HEADER_HEIGHT = 74.0
if not sections:
return 260
return max(sum(height for _key, height in sections) - SECTION_GAP, 0.0)
return 260 + RELOCATED_HEADER_HEIGHT
return max(sum(height for _key, height in sections) - SECTION_GAP, 0.0) + RELOCATED_HEADER_HEIGHT
def _build_sections(self, width: float) -> list[tuple[str, float]]:
sections: list[tuple[str, float]] = []
@@ -339,6 +339,10 @@ class DrivingModelManagerView(AetherInteractiveMixin, Widget):
utility_rows = self._controller.utility_rows()
y = rect.y + self._scroll_offset
RELOCATED_HEADER_HEIGHT = 74.0
self._draw_relocated_header(rect.x, y, width)
y += RELOCATED_HEADER_HEIGHT
if not installed and not available and not utility_rows:
self._draw_empty_state(rl.Rectangle(rect.x, y + 36, width, 200))
return
@@ -366,7 +370,10 @@ class DrivingModelManagerView(AetherInteractiveMixin, Widget):
)
def _draw_model_section(self, x: float, y: float, width: float, title: str, entries: list[ModelCatalogEntry]) -> float:
draw_section_header(rl.Rectangle(x, y, width, SECTION_HEADER_HEIGHT), title, style=PANEL_STYLE)
trailing = ""
if title == tr("On Device"):
trailing = tr("Current: {}").format(self._controller._current_model_name)
draw_section_header(rl.Rectangle(x, y, width, SECTION_HEADER_HEIGHT), title, trailing_text=trailing, style=PANEL_STYLE)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
group_rect = rl.Rectangle(x, y, width, len(entries) * ROW_HEIGHT)
@@ -43,7 +43,7 @@ CUSTOM_METRICS = AetherListMetrics(
panel_padding_x=16,
panel_padding_top=16,
panel_padding_bottom=12,
header_height=198,
header_height=0,
section_gap=12,
section_header_height=28,
section_header_gap=8,
@@ -83,7 +83,7 @@ class SteeringManagerView(PanelManagerView):
self._controller = controller
self._shell_rect = rl.Rectangle(0, 0, 0, 0)
self._toggle_grid = TileGrid(columns=2, padding=12, min_tile_width=100, min_tile_height=130.0, max_tile_height=180.0)
self._toggle_grid = TileGrid(columns=2, padding=12, force_square=True, min_tile_width=100, min_tile_height=130.0, max_tile_height=180.0)
self._toggle_grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
self._child(self._toggle_grid)
@@ -103,9 +103,7 @@ class SteeringManagerView(PanelManagerView):
self._controller._on_select(value)
def _draw_header(self, rect: rl.Rectangle):
draw_settings_panel_header(rect, tr("Steering"),
tr("Fine-tune lateral control, lane changes, and steering behavior."),
subtitle_size=22)
pass
def _build_toggle_defs(self) -> list[dict]:
p = self._controller._params
@@ -240,24 +240,9 @@ class LongitudinalManagerView(AetherSettingsView):
margin_x = 12.0
margin_top = 16.0
margin_bottom = 16.0
header_h = 90.0
gap_after_header = 16.0
# Draw the header at the top of rect:
header_rect = rl.Rectangle(rect.x + margin_x, rect.y + margin_top, rect.width - margin_x * 2, header_h)
self._draw_header(header_rect)
# Draw a nice separator line under the header:
divider_y = rect.y + margin_top + header_h + 8.0
rl.draw_line_ex(
rl.Vector2(rect.x + margin_x, divider_y),
rl.Vector2(rect.x + rect.width - margin_x, divider_y),
2.0,
rl.Color(255, 255, 255, 16)
)
# Compute remaining scroll rect:
grid_top = divider_y + gap_after_header
# Compute remaining scroll rect starting directly from the top
grid_top = rect.y + margin_top
grid_h = rect.y + rect.height - grid_top - margin_bottom
self._scroll_rect = rl.Rectangle(rect.x + margin_x, grid_top, rect.width - margin_x * 2, grid_h)
@@ -5,7 +5,7 @@ import pyray as rl
from openpilot.common.params import Params
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
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.sounds import StarPilotSoundsLayout
@@ -237,22 +237,64 @@ class StarPilotLayout(Widget):
def _render(self, rect: rl.Rectangle):
import openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid as aethergrid
metrics = aethergrid.AETHER_LIST_METRICS
TOP_BAR_HEIGHT = 80
content_rect = rl.Rectangle(rect.x, rect.y + TOP_BAR_HEIGHT, rect.width, rect.height - TOP_BAR_HEIGHT)
# Standardize width to perfectly match subpanel shells
shell_w = min(rect.width - metrics.outer_margin_x * 2, metrics.max_content_width)
shell_x = rect.x + (rect.width - shell_w) / 2
# 0. Draw Tinted Cockpit Glass Background as a floating, rounded panel
glass_rect = rl.Rectangle(shell_x, rect.y + 18, shell_w, TOP_BAR_HEIGHT - 24)
aethergrid._draw_rounded_fill(glass_rect, rl.Color(18, 16, 24, 180), radius_px=16)
aethergrid._draw_rounded_stroke(glass_rect, rl.Color(255, 255, 255, 20), radius_px=16)
# 1. Draw breadcrumbs in top bar — pass the full pill rect for proper vertical centering
# Reserve space on the right for the badge + time (estimated ~260px)
crumb_rect = rl.Rectangle(glass_rect.x, glass_rect.y, glass_rect.width - 260, glass_rect.height)
aethergrid.draw_breadcrumbs(crumb_rect)
# 2. Draw Time/Clock on right
import time
from openpilot.system.ui.lib.text_measure import measure_text_cached
current_time = time.strftime("%I:%M %p").lstrip("0")
font = gui_app.font(FontWeight.SEMI_BOLD)
font_size = 28
time_w = measure_text_cached(font, current_time, font_size).x
time_x = glass_rect.x + glass_rect.width - time_w - 20
time_y = glass_rect.y + (glass_rect.height - 28) / 2
rl.draw_text_ex(font, current_time, rl.Vector2(time_x, time_y), font_size, 0, rl.Color(160, 170, 185, 255))
# 3. Draw Network/Wifi Badge
from openpilot.selfdrive.ui.ui_state import ui_state
network_type = ui_state.sm["deviceState"].networkType if ui_state.sm.valid.get("deviceState", False) else 0
if network_type == 1:
network_str = "WIFI"
network_color = rl.Color(34, 197, 94, 255)
elif network_type in (2, 3, 4, 5):
network_str = "CELL"
network_color = rl.Color(59, 130, 246, 255)
else:
network_str = "OFFLINE"
network_color = rl.Color(239, 68, 68, 255)
badge_w = measure_text_cached(font, network_str, 20).x + 24
badge_rect = rl.Rectangle(time_x - badge_w - 20, glass_rect.y + (glass_rect.height - 32) / 2, badge_w, 32)
rl.draw_rectangle_rounded(badge_rect, 0.35, 8, rl.Color(network_color.r, network_color.g, network_color.b, 24))
rl.draw_rectangle_rounded_lines_ex(badge_rect, 0.35, 8, 1.5, rl.Color(network_color.r, network_color.g, network_color.b, 70))
badge_text_pos = rl.Vector2(badge_rect.x + 12, badge_rect.y + 6)
rl.draw_text_ex(font, network_str, badge_text_pos, 20, 0, network_color)
# 5. Render active content panel
if self._current_panel == StarPilotPanelType.MAIN:
if self._current_category_idx is not None:
# We are in a category folder, draw the breadcrumbs header
header_rect = rl.Rectangle(rect.x + 16, rect.y + 28, rect.width - 32, 96)
cat = self.CATEGORIES[self._current_category_idx]
aethergrid.draw_settings_panel_header(header_rect, tr(cat["title"]), subtitle=None)
# Adjust grid to fit under header
grid_rect = rl.Rectangle(rect.x, header_rect.y + header_rect.height, rect.width, rect.height - header_rect.height - 28)
self._main_grid.render(grid_rect)
else:
self._main_grid.render(rect)
grid_rect = rl.Rectangle(shell_x, content_rect.y + metrics.outer_margin_y, shell_w, content_rect.height - metrics.outer_margin_y * 2)
self._main_grid.render(grid_rect)
else:
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(rect)
panel.instance.render(content_rect)
def _handle_mouse_press(self, mouse_pos: MousePos):
import openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid as aethergrid
@@ -40,6 +40,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
draw_soft_card,
init_list_panel,
_point_hits,
wrap_text,
)
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.starpilot.common.maps_catalog import (
@@ -85,7 +86,7 @@ STATUS_REMOVE_HEIGHT = 40
STATUS_METRIC_GAP = 18
STATUS_SELECTION_CHIP_HEIGHT = 30
PANEL_STYLE = DEFAULT_PANEL_STYLE
MAPS_METRICS = replace(AETHER_LIST_METRICS, header_height=274)
MAPS_METRICS = replace(AETHER_LIST_METRICS, header_height=0)
COUNTRIES_SECTION = next(section for section in MAPS_CATALOG if section["key"] == "countries")
STATES_SECTION = next(section for section in MAPS_CATALOG if section["key"] == "states")
@@ -1115,13 +1116,22 @@ class StarPilotMapsLayout(StarPilotPanel):
return tr("Ready to download {}.").format(self._selection_preview_text())
def _measure_content_height(self, width: float) -> float:
return self._browser_card._measure_height(width)
RELOCATED_HEADER_HEIGHT = 156.0
return self._browser_card._measure_height(width) + RELOCATED_HEADER_HEIGHT
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
self._browser_card.set_parent_rect(rect)
self._status_card.set_parent_rect(rect)
y = rect.y + self._scroll_offset
# 1. Draw Status Card
status_rect = rl.Rectangle(rect.x, y, width, 144.0)
self._status_card.render(status_rect)
RELOCATED_HEADER_HEIGHT = 156.0
y += RELOCATED_HEADER_HEIGHT
browser_height = self._browser_card._measure_height(width)
self._browser_card.render(rl.Rectangle(rect.x, y, width, browser_height))
@@ -1129,13 +1139,7 @@ class StarPilotMapsLayout(StarPilotPanel):
self.set_rect(rect)
frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE, MAPS_METRICS)
hdr = frame.header
draw_settings_panel_header(hdr, tr("Map Data"), tr("Use offline maps for speed-limit control and keep only the regions you need."),
max_title_width=1.0, max_subtitle_width=0.60)
header_status_y = hdr.y + 82 + HEADER_SUBTITLE_HEIGHT + 12
header_status_rect = rl.Rectangle(hdr.x, header_status_y, hdr.width, hdr.y + hdr.height - header_status_y - HEADER_BOTTOM_GAP)
self._status_card.render(header_status_rect)
self._status_card.set_parent_rect(scroll_rect)
scroll_content_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y, scroll_rect.width, scroll_rect.height)
self._content_height = self._measure_content_height(content_width)
@@ -45,7 +45,7 @@ SOUNDS_PANEL_METRICS = replace(
outer_margin_y=14,
panel_padding_top=16,
panel_padding_bottom=14,
header_height=125,
header_height=0,
)
@@ -76,7 +76,7 @@ class SoundsManagerView(PanelManagerView):
)
def _init_toggles(self):
self._toggle_grid = TileGrid(columns=2, padding=12, min_tile_height=130.0, max_tile_height=180.0)
self._toggle_grid = TileGrid(columns=2, padding=12, force_square=True, min_tile_height=130.0, max_tile_height=180.0)
self._child(self._toggle_grid)
self._page_grid = self._toggle_grid
@@ -251,7 +251,7 @@ class SoundsManagerView(PanelManagerView):
# 9 adjustors (8 volume keys + 1 cooldown key) and 2 group headers ("SYSTEM STATE" and "INFORMATIONAL")
left_h = ((len(self._controller.VOLUME_KEYS) + 1) * default_adjustor_h)
left_h += (2 * (GROUP_HEADER_HEIGHT + GROUP_HEADER_GAP))
left_natural_container_h = left_h + 16
left_natural_container_h = left_h + 16.0
tiles_needed_h = self.measure_page_grid_height(self._toggle_grid, col_width - 24) + 24
right_natural_container_h = tiles_needed_h
@@ -281,22 +281,7 @@ class SoundsManagerView(PanelManagerView):
return self._compute_two_column_height(section_overhead + max_container_h)
def _draw_header(self, rect: rl.Rectangle):
draw_settings_panel_header(rect, tr("Sounds & Alerts"), tr("Manage system volumes and custom alert toggles."), subtitle_size=24)
btn_w = 120.0
btn_h = 38.0
btn_x = rect.x + rect.width - btn_w
btn_y = rect.y + (rect.height - btn_h) / 2
self._reset_rect = rl.Rectangle(btn_x, btn_y, btn_w, btn_h)
hovered = _point_hits(gui_app.last_mouse_event.pos, self._reset_rect, None, pad_x=6, pad_y=0)
pressed = self._pressed_target == "action:restore_defaults"
draw_action_pill(
self._reset_rect, tr("Reset All"),
rl.Color(255, 255, 255, 8 if not pressed else 14),
rl.Color(255, 255, 255, 18 if not pressed else 28),
AetherListColors.SUBTEXT if not hovered else AetherListColors.HEADER,
)
pass
def _draw_scroll_content(self, rect: rl.Rectangle, content_width: float):
y = rect.y + self._scroll_offset
@@ -310,6 +295,25 @@ class SoundsManagerView(PanelManagerView):
rl.Rectangle(rect.x + col_width + SECTION_GAP, y, col_width, SECTION_HEADER_HEIGHT),
tr("Alerts"), style=PANEL_STYLE
)
# Draw Reset All button aligned with the Volume header
btn_w = 120.0
btn_h = 32.0
btn_x = rect.x + col_width - btn_w - 8
btn_y = y + (SECTION_HEADER_HEIGHT - btn_h) / 2
self._reset_rect = rl.Rectangle(btn_x, btn_y, btn_w, btn_h)
self._interactive_rects["action:restore_defaults"] = self._reset_rect
hovered = _point_hits(gui_app.last_mouse_event.pos, self._reset_rect, self._scroll_rect, pad_x=6, pad_y=0)
pressed = self._pressed_target == "action:restore_defaults"
draw_action_pill(
self._reset_rect, tr("Reset All"),
rl.Color(255, 255, 255, 8 if not pressed else 14),
rl.Color(255, 255, 255, 18 if not pressed else 28),
AetherListColors.SUBTEXT if not hovered else AetherListColors.HEADER,
)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
self._draw_volume_column(y, rect.x, col_width)
@@ -51,6 +51,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
_draw_rounded_stroke,
_point_hits,
_draw_text_fit_common,
wrap_text,
)
from openpilot.starpilot.common.connect_server import prepare_konik_server_switch
@@ -305,7 +306,7 @@ class SystemSettingsManagerView(PanelManagerView):
self._basics_tile_grid_h = 0.0
self._connectivity_tile_grid = TileGrid(columns=2, padding=12, min_tile_height=130.0, max_tile_height=180.0)
self._connectivity_tile_grid = TileGrid(columns=2, padding=12, force_square=True, min_tile_height=130.0, max_tile_height=180.0)
for toggle_def in self._toggle_defs:
tile = self._make_toggle_tile(toggle_def)
self._connectivity_tile_grid.add_tile(tile)
@@ -433,17 +434,80 @@ class SystemSettingsManagerView(PanelManagerView):
gui_app.push_widget(AetherBackupsCareDialog(self._controller))
def _on_frame_created(self, frame) -> None:
self._drive_mode_control.set_parent_rect(frame.header)
self._drive_mode_control.set_parent_rect(self._scroll_rect)
def _draw_header(self, rect: rl.Rectangle):
draw_settings_panel_header(rect, tr("System Settings"),
tr("Manage display, backups, connectivity, and device maintenance from one touch-first panel."),
max_title_width=0.60, max_subtitle_width=0.62)
pass
# First Aid Button in top right
def _measure_content_height(self, width: float) -> float:
RELOCATED_HEADER_HEIGHT = 80.0
display_h = self._section_block_height(self._slider_section_height(self._display_slider_keys, width))
power_h = self._section_block_height(self._slider_section_height(self._power_slider_keys, width))
if self._uses_two_columns(width):
column_w = self._column_width(width)
# Reset custom heights to calculate natural measurements first
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = None
self._connectivity_tile_grid._tile_height = None
display_container_h = self._slider_section_height(self._display_slider_keys, column_w)
power_container_h = self._slider_section_height(self._power_slider_keys, column_w)
left_overhead = 8.0 + 2 * (GROUP_HEADER_HEIGHT + GROUP_HEADER_GAP) + SECTION_GAP
left_natural_content_h = left_overhead + display_container_h + power_container_h + RELOCATED_HEADER_HEIGHT
tiles_content_h = self.measure_page_grid_height(self._connectivity_tile_grid, column_w - 24)
right_natural_container_h = tiles_content_h + 24
max_natural_h = max(left_natural_content_h, right_natural_container_h)
if self._scroll_rect:
available_container_h = self._scroll_rect.height - 12.0
else:
available_container_h = max_natural_h
# Always stretch container to fill available height, preventing empty space at bottom
max_container_h = available_container_h
# Scale adjustors if needed
if max_container_h < max_natural_h:
scale_f = (max_container_h - left_overhead - RELOCATED_HEADER_HEIGHT) / (left_natural_content_h - left_overhead - RELOCATED_HEADER_HEIGHT)
row_h = max(60.0, 94.0 * scale_f)
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = row_h
self._system_max_container_h = max_container_h
return self._compute_two_column_height(max_container_h)
else:
# Ensure defaults are restored in single column mode
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = None
self._connectivity_tile_grid._tile_height = None
tiles_content_h = self.measure_page_grid_height(self._connectivity_tile_grid, width - 24)
return self._stacked_section_height([display_h, power_h, tiles_content_h + 24]) + RELOCATED_HEADER_HEIGHT
def _slider_section_height(self, keys: list[str], width: float) -> float:
total = 0.0
for key in keys:
adjustor = self._adjustor_rows[key]
total += adjustor.measure_height(width)
return total
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
y = rect.y + self._scroll_offset
self._draw_basics_tab(y, rect.x, width)
def _draw_basics_tab(self, y: float, x: float, width: float):
# Relocated Header elements drawn at the top
col_w = self._column_width(width) if self._uses_two_columns(width) else width
# 1. Draw First Aid Button
btn_w, btn_h = 68.0, 68.0
btn_x = rect.x + rect.width - btn_w
btn_y = rect.y + 4.0
btn_x = x + col_w - btn_w - 8
btn_y = y
btn_rect = rl.Rectangle(btn_x, btn_y, btn_w, btn_h)
hovered, pressed = self._interactive_state("static:first_aid", btn_rect)
@@ -466,81 +530,19 @@ class SystemSettingsManagerView(PanelManagerView):
icon_color = PANEL_STYLE.accent if (hovered or pressed) else AetherListColors.HEADER
draw_custom_icon("first_aid", icon_x, icon_y, s, icon_color)
content_width = rect.width - AETHER_LIST_METRICS.content_right_gutter
summary_y = rect.y + 126
control_rect = rl.Rectangle(rect.x, summary_y, content_width, 108)
# 2. Draw Drive Mode Control (AetherSegmentedControl)
control_rect = rl.Rectangle(x, y, col_w - btn_w - 12.0, btn_h)
self._drive_mode_control.render(control_rect)
def _measure_content_height(self, width: float) -> float:
display_h = self._section_block_height(self._slider_section_height(self._display_slider_keys, width))
power_h = self._section_block_height(self._slider_section_height(self._power_slider_keys, width))
RELOCATED_HEADER_HEIGHT = 80.0
y += RELOCATED_HEADER_HEIGHT
if self._uses_two_columns(width):
column_w = self._column_width(width)
# Reset custom heights to calculate natural measurements first
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = None
self._connectivity_tile_grid._tile_height = None
adj_container_h = self._system_max_container_h - RELOCATED_HEADER_HEIGHT
display_container_h = self._slider_section_height(self._display_slider_keys, column_w)
power_container_h = self._slider_section_height(self._power_slider_keys, column_w)
left_overhead = 8.0 + 2 * (GROUP_HEADER_HEIGHT + GROUP_HEADER_GAP) + SECTION_GAP
left_natural_content_h = left_overhead + display_container_h + power_container_h
tiles_content_h = self.measure_page_grid_height(self._connectivity_tile_grid, column_w - 24)
right_natural_container_h = tiles_content_h + 24
max_natural_h = max(left_natural_content_h, right_natural_container_h)
if self._scroll_rect:
available_container_h = self._scroll_rect.height - 12.0
else:
available_container_h = max_natural_h
# Always stretch container to fill available height, preventing empty space at bottom
max_container_h = available_container_h
# Scale adjustors if needed
if max_container_h < max_natural_h:
scale_f = (max_container_h - left_overhead) / (left_natural_content_h - left_overhead)
row_h = max(60.0, 94.0 * scale_f)
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = row_h
self._system_max_container_h = max_container_h
pass
return self._compute_two_column_height(max_container_h)
else:
# Ensure defaults are restored in single column mode
for key in self._display_slider_keys + self._power_slider_keys:
self._adjustor_rows[key].custom_row_height = None
self._connectivity_tile_grid._tile_height = None
tiles_content_h = self.measure_page_grid_height(self._connectivity_tile_grid, width - 24)
return self._stacked_section_height([display_h, power_h, tiles_content_h + 24])
def _slider_section_height(self, keys: list[str], width: float) -> float:
total = 0.0
for key in keys:
adjustor = self._adjustor_rows[key]
total += adjustor.measure_height(width)
return total
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
y = rect.y + self._scroll_offset
self._draw_basics_tab(y, rect.x, width)
def _draw_basics_tab(self, y: float, x: float, width: float):
if self._uses_two_columns(width):
column_w = self._column_width(width)
display_container_h = self._slider_section_height(self._display_slider_keys, column_w)
power_container_h = self._slider_section_height(self._power_slider_keys, column_w)
# Single unified shell for the left side
draw_list_group_shell(rl.Rectangle(x, y, column_w, self._system_max_container_h), style=PANEL_STYLE)
# Draw unified shell for the adjustors
draw_list_group_shell(rl.Rectangle(x, y, column_w, adj_container_h), style=PANEL_STYLE)
current_y = y + 8
current_y = draw_group_header(x + 24, current_y, column_w - 48, tr("DISPLAY"))
@@ -553,8 +555,9 @@ class SystemSettingsManagerView(PanelManagerView):
for index, key in enumerate(self._power_slider_keys):
current_y = self._draw_slider_row(rl.Rectangle(x, current_y, column_w, 0), key, is_last=index == len(self._power_slider_keys) - 1)
self._draw_two_column_tile_grid(self._connectivity_tile_grid, x + column_w + self.COLUMN_GAP, y, column_w, self._system_max_container_h)
self._draw_two_column_tile_grid(self._connectivity_tile_grid, x + column_w + self.COLUMN_GAP, y - RELOCATED_HEADER_HEIGHT, column_w, self._system_max_container_h)
return
y = self._draw_slider_section(y, x, width, tr("Display"), self._display_slider_keys)
y += SECTION_GAP
y = self._draw_slider_section(y, x, width, tr("Power"), self._power_slider_keys)
@@ -32,6 +32,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
_draw_rounded_fill,
_draw_rounded_stroke,
draw_status_badges,
wrap_text,
)
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.selfdrive.ui.lib.fingerprint_catalog import (
@@ -78,7 +79,7 @@ CUSTOM_METRICS = AetherListMetrics(
panel_padding_x=16,
panel_padding_top=16,
panel_padding_bottom=12,
header_height=198,
header_height=0,
section_gap=12,
section_header_height=28,
section_header_gap=8,
@@ -105,7 +106,7 @@ class VehicleSettingsManagerView(PanelManagerView):
self._controller = controller
self._shell_rect = rl.Rectangle(0, 0, 0, 0)
self._toggle_grid = TileGrid(columns=2, padding=12, min_tile_width=100, min_tile_height=130.0, max_tile_height=180.0)
self._toggle_grid = TileGrid(columns=2, padding=12, force_square=True, min_tile_width=100, min_tile_height=130.0, max_tile_height=180.0)
self.register_page_grid(self._toggle_grid)
self._last_make = ""
@@ -244,13 +245,7 @@ class VehicleSettingsManagerView(PanelManagerView):
self._controller._on_select(value)
def _draw_header(self, rect: rl.Rectangle):
draw_settings_panel_header(rect, tr("Vehicle Settings"),
tr("Configure vehicle fingerprint, driving features, and steering controls."),
subtitle_size=22)
summary_y = rect.y + 78 + self.HEADER_SUBTITLE_HEIGHT + self.HEADER_SUMMARY_GAP
summary_rect = rl.Rectangle(rect.x, summary_y, rect.width, min(self.HEADER_CARD_HEIGHT, rect.y + rect.height - summary_y))
self._draw_summary_card(summary_rect)
pass
def _draw_summary_card(self, rect: rl.Rectangle):
draw_soft_card(rect, PANEL_STYLE.surface_fill, PANEL_STYLE.surface_border)
@@ -319,6 +314,7 @@ class VehicleSettingsManagerView(PanelManagerView):
def _measure_content_height(self, width: float) -> float:
self._check_rebuild_grid()
cs = starpilot_state.car_state
RELOCATED_HEADER_HEIGHT = 112.0
# Left Column heights
identity_rows = 2
@@ -344,14 +340,14 @@ class VehicleSettingsManagerView(PanelManagerView):
column_w = self._column_width(width)
tiles_content_h = self.measure_page_grid_height(self._toggle_grid, column_w - 24)
right_natural_container_h = tiles_content_h + 24
left_natural_content_h = identity_h + SECTION_GAP + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + steering_h
left_natural_content_h = identity_h + SECTION_GAP + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP + steering_h + RELOCATED_HEADER_HEIGHT
max_container_h = max(left_natural_content_h, right_natural_container_h)
self._vehicle_max_container_h = max_container_h
self._vehicle_section_gap = max(SECTION_GAP, max_container_h - (identity_h + steering_h + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP))
self._vehicle_section_gap = max(SECTION_GAP, (max_container_h - RELOCATED_HEADER_HEIGHT) - (identity_h + steering_h + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP))
return self._compute_two_column_height(max_container_h + SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP)
return left_h + tiles_height
return left_h + tiles_height + RELOCATED_HEADER_HEIGHT
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
self._interactive_rects.clear()
@@ -362,6 +358,16 @@ class VehicleSettingsManagerView(PanelManagerView):
self._check_rebuild_grid()
cs = starpilot_state.car_state
# Relocated Header elements drawn at the top of the left column
col_w = self._column_width(width) if self._uses_two_columns(width) else width
# 1. Draw Summary Card
summary_rect = rl.Rectangle(x, y, col_w, 100.0)
self._draw_summary_card(summary_rect)
RELOCATED_HEADER_HEIGHT = 112.0
y += RELOCATED_HEADER_HEIGHT
identity_rows = [
{"target_id": "select:CarMake", "type": "select", "title": tr("Car Make"),
"get_value": self._controller._get_display_make, "pill_width": 160,
@@ -408,7 +414,7 @@ class VehicleSettingsManagerView(PanelManagerView):
# Right Column: Features
if self._toggle_grid.tiles:
rx = x + column_w + self.COLUMN_GAP
self._draw_two_column_tile_grid(self._toggle_grid, rx, y, column_w, self._vehicle_max_container_h, title=tr("Features"), style=PANEL_STYLE)
self._draw_two_column_tile_grid(self._toggle_grid, rx, y - RELOCATED_HEADER_HEIGHT, column_w, self._vehicle_max_container_h, title=tr("Features"), style=PANEL_STYLE)
else:
# Single Column Stacked Layout
draw_section_header(rl.Rectangle(x, y, width, SECTION_HEADER_HEIGHT), tr("Vehicle Identity"), style=PANEL_STYLE)