BigUI WIP: Hänsel und Gretel

BigUI WIP: Hänsel und Gretel
This commit is contained in:
firestarsdog
2026-06-21 05:26:46 -04:00
parent 34813dee41
commit bf8204e9a2
10 changed files with 367 additions and 19 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 = 210
header_height: int = 244
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=96)
COMPACT_PANEL_METRICS = replace(AETHER_LIST_METRICS, header_height=125)
@dataclass(frozen=True, slots=True)
@@ -439,18 +439,34 @@ class AetherInteractiveMixin:
pass
def _handle_mouse_press(self, mouse_pos: MousePos):
global PRESSED_BREADCRUMB
breadcrumb_target = resolve_interactive_target(mouse_pos, BREADCRUMB_RECTS, None, pad_x=12, pad_y=12)
if breadcrumb_target:
PRESSED_BREADCRUMB = breadcrumb_target
self._pressed_target = self._target_at(mouse_pos)
self._can_click = True
def _handle_mouse_event(self, mouse_event: MouseEvent):
if not self._scroll_panel.is_touch_valid():
global PRESSED_BREADCRUMB
if self._scroll_panel and 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:
self._pressed_target = None
if PRESSED_BREADCRUMB and resolve_interactive_target(mouse_event.pos, BREADCRUMB_RECTS, None, pad_x=12, pad_y=12) != PRESSED_BREADCRUMB:
PRESSED_BREADCRUMB = None
def _handle_mouse_release(self, mouse_pos: MousePos):
target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None
global PRESSED_BREADCRUMB
target = self._target_at(mouse_pos) if self._scroll_panel and self._scroll_panel.is_touch_valid() else None
breadcrumb_target = resolve_interactive_target(mouse_pos, BREADCRUMB_RECTS, None, pad_x=12, pad_y=12)
if breadcrumb_target and breadcrumb_target == PRESSED_BREADCRUMB and self._can_click:
handle_breadcrumb_click(breadcrumb_target)
PRESSED_BREADCRUMB = None
if self._pressed_target is not None and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
@@ -843,8 +859,120 @@ class PanelManagerView(AetherInteractiveMixin, Widget):
self._on_page_changed()
PANEL_HEADER_TITLE_Y: int = 4
PANEL_HEADER_SUBTITLE_Y: int = 48
BREADCRUMB_RECTS: dict[str, rl.Rectangle] = {}
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
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:]
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:
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:
path.append((panel_title, "action:panel"))
for i, widget in enumerate(pushed_widgets[:-1]):
if hasattr(widget, '_header_title') and widget._header_title:
path.append((widget._header_title.upper(), f"action:nav_stack:{i+1}"))
return path
def handle_breadcrumb_click(target: 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)
if not layout:
return
if target == "action:home":
while len(gui_app._nav_stack) > 1:
gui_app.pop_widget()
layout._panel_stack.clear()
layout._current_category_idx = None
layout._set_current_panel(StarPilotPanelType.MAIN)
elif target == "action:category":
while len(gui_app._nav_stack) > 1:
gui_app.pop_widget()
layout._panel_stack.clear()
layout._set_current_panel(StarPilotPanelType.MAIN)
elif target == "action:panel":
while len(gui_app._nav_stack) > 1:
gui_app.pop_widget()
layout._panel_stack.clear()
layout._update_sub_panel_visibility()
elif target.startswith("action:nav_stack:"):
target_idx = int(target.split(":")[-1])
while len(gui_app._nav_stack) > target_idx + 1:
gui_app.pop_widget()
def draw_breadcrumbs(start_pos: rl.Vector2, max_width: float) -> None:
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):
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
PANEL_HEADER_TITLE_Y: int = 34
PANEL_HEADER_SUBTITLE_Y: int = 78
PANEL_HEADER_TITLE_FONT_SIZE: int = 40
PANEL_HEADER_SUBTITLE_FONT_SIZE: int = 22
PANEL_HEADER_TITLE_FONT: FontWeight = FontWeight.SEMI_BOLD
@@ -861,6 +989,8 @@ 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:
@@ -868,6 +998,7 @@ def draw_settings_panel_header(header_rect: rl.Rectangle, title: str, subtitle:
gui_label(subtitle_rect, subtitle, subtitle_size, subtitle_color, subtitle_weight)
def draw_soft_card(rect: rl.Rectangle, fill: rl.Color, border: rl.Color, radius: float = 0.08, segments: int = 18):
radius_px = radius * min(rect.width, rect.height)
_draw_rounded_fill(rect, fill, radius_px=radius_px, segments=segments)
@@ -2785,6 +2916,160 @@ class AetherCategoryTileView(AetherSettingsView):
draw_settings_panel_header(title_rect, title, subtitle)
# ── AetherSubMenuTileView — category panel containing navigation HubTiles ──
class AetherSubMenuTileView(AetherSettingsView):
"""Reusable nested tile view that displays HubTiles for sub-menu navigation."""
def __init__(self, controller, title: str, tiles_data: list[dict],
*, 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._tiles_data = tiles_data
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)
for d in self._tiles_data:
tile = HubTile(
title=d["title"],
desc=d["desc"],
icon_key=d.get("icon"),
on_click=d["on_click"],
bg_color=self._color,
)
self._tile_grid.add_tile(tile)
self._back_btn_rect = None
def _measure_content_height(self, width: float) -> float:
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)
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()
# 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)
)
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__()
@@ -223,7 +223,7 @@ class AppearanceManagerView(AetherSettingsView):
margin_x = 12.0
margin_top = 16.0
margin_bottom = 16.0
header_h = 90.0
header_h = 125.0
gap_after_header = 16.0
# Draw the header at the top of rect:
@@ -75,7 +75,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=210)
DRIVING_MODEL_METRICS = replace(AETHER_LIST_METRICS, header_height=244)
CONFIRM_TIMEOUT_SECONDS = 3.0
TRANSITION_SECONDS = 0.24
PANEL_STYLE = DEFAULT_PANEL_STYLE
@@ -43,7 +43,7 @@ CUSTOM_METRICS = AetherListMetrics(
panel_padding_x=16,
panel_padding_top=16,
panel_padding_bottom=12,
header_height=164,
header_height=198,
section_gap=12,
section_header_height=28,
section_header_gap=8,
@@ -23,6 +23,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SettingSection,
AetherSettingsView,
AetherCategoryTileView,
AetherSubMenuTileView,
TileGrid,
HubTile,
hex_to_color,
@@ -133,10 +134,41 @@ class LongitudinalManagerView(AetherSettingsView):
"icon": "display",
"color": "#8B5CF6",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
AetherSubMenuTileView(
self._controller,
tr("Adaptive Speed Controls"),
self._controller._adaptive_rows,
[
{
"title": tr("Conditional Experimental"),
"desc": tr("Configure triggers and threshold speeds for automated Experimental Mode switching."),
"icon": "steering",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Conditional Experimental"),
self._controller._conditional_experimental_rows,
color="#8B5CF6",
subtitle=tr("Configure triggers and threshold speeds for automated Experimental Mode switching."),
panel_style=self._panel_style,
)
),
},
{
"title": tr("Curve Speed Controller"),
"desc": tr("Configure speed control on curves and reset collected calibration data."),
"icon": "navigate",
"on_click": lambda: gui_app.push_widget(
AetherCategoryTileView(
self._controller,
tr("Curve Speed Controller"),
self._controller._curve_speed_controller_rows,
color="#8B5CF6",
subtitle=tr("Configure speed control on curves and reset collected calibration data."),
panel_style=self._panel_style,
)
),
},
],
color="#8B5CF6",
subtitle=tr("Configure Curve Speed Controller and Conditional Experimental Mode triggers."),
panel_style=self._panel_style,
@@ -469,7 +501,7 @@ class StarPilotLongitudinalLayout(_SettingsPage):
))
# ── 4. Adaptive Speed Controls Rows (CES + CSC) ──
self._adaptive_rows = [
self._conditional_experimental_rows = [
SettingRow("ConditionalExperimental", "toggle", tr_noop("Conditional Experimental Mode"),
subtitle="",
get_state=lambda: self._params.get_bool("ConditionalExperimental"),
@@ -539,6 +571,9 @@ class StarPilotLongitudinalLayout(_SettingsPage):
get_state=lambda: self._params.get_bool("ShowCEMStatus"),
set_state=lambda s: self._params.put_bool("ShowCEMStatus", s),
visible=ce_on),
]
self._curve_speed_controller_rows = [
SettingRow("CurveSpeed", "toggle", tr_noop("Curve Speed Controller"),
subtitle="",
get_state=lambda: self._params.get_bool("CurveSpeedController"),
@@ -62,6 +62,8 @@ class StarPilotLayout(Widget):
self._depth_callback: Callable | None = None
self._settings_layout = None
StarPilotLayout.active_instance = self
self._panel_stack: list[tuple[StarPilotPanelType, str]] = []
self._sub_panel_callbacks: dict[str, Callable] = {}
@@ -228,13 +230,39 @@ class StarPilotLayout(Widget):
self._update_depth()
def _render(self, rect: rl.Rectangle):
import openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid as aethergrid
if self._current_panel == StarPilotPanelType.MAIN:
self._main_grid.render(rect)
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)
else:
panel = self._panels[self._current_panel]
if panel.instance:
panel.instance.render(rect)
def _handle_mouse_press(self, mouse_pos: MousePos):
import openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid as aethergrid
aethergrid.PRESSED_BREADCRUMB = aethergrid.resolve_interactive_target(mouse_pos, aethergrid.BREADCRUMB_RECTS, None, pad_x=12, pad_y=12)
def _handle_mouse_release(self, mouse_pos: MousePos):
import openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid as aethergrid
target = aethergrid.resolve_interactive_target(mouse_pos, aethergrid.BREADCRUMB_RECTS, None, pad_x=12, pad_y=12)
if target and target == aethergrid.PRESSED_BREADCRUMB:
aethergrid.handle_breadcrumb_click(target)
aethergrid.PRESSED_BREADCRUMB = None
def _handle_mouse_event(self, mouse_event):
pass
def show_event(self):
super().show_event()
if self._current_panel != StarPilotPanelType.MAIN:
@@ -85,7 +85,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=240)
MAPS_METRICS = replace(AETHER_LIST_METRICS, header_height=274)
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")
@@ -1133,7 +1133,7 @@ class StarPilotMapsLayout(StarPilotPanel):
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 + 48 + HEADER_SUBTITLE_HEIGHT + 12
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)
@@ -46,7 +46,7 @@ SOUNDS_PANEL_METRICS = replace(
outer_margin_y=14,
panel_padding_top=16,
panel_padding_bottom=14,
header_height=88,
header_height=125,
)
@@ -462,7 +462,7 @@ class SystemSettingsManagerView(PanelManagerView):
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 + 92
summary_y = rect.y + 126
control_rect = rl.Rectangle(rect.x, summary_y, content_width, 108)
self._drive_mode_control.render(control_rect)
@@ -75,7 +75,7 @@ CUSTOM_METRICS = AetherListMetrics(
panel_padding_x=16,
panel_padding_top=16,
panel_padding_bottom=12,
header_height=164,
header_height=198,
section_gap=12,
section_header_height=28,
section_header_gap=8,
@@ -239,7 +239,7 @@ class VehicleSettingsManagerView(PanelManagerView):
tr("Configure vehicle fingerprint, driving features, and steering controls."),
subtitle_size=22)
summary_y = rect.y + 44 + self.HEADER_SUBTITLE_HEIGHT + self.HEADER_SUMMARY_GAP
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)