BigUI WIP: Refinement/More Extraction

This commit is contained in:
firestarsdog
2026-05-19 00:01:13 -04:00
parent 123adc7edc
commit 32c088ac00
11 changed files with 605 additions and 771 deletions
@@ -6,6 +6,7 @@ import pyray as rl
from collections.abc import Callable
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.label import gui_label
@@ -83,11 +84,10 @@ def _snap(value: float) -> float:
def _snap_rect(rect: rl.Rectangle) -> rl.Rectangle:
x = _snap(rect.x)
y = _snap(rect.y)
right = _snap(rect.x + rect.width)
bottom = _snap(rect.y + rect.height)
return rl.Rectangle(x, y, max(0.0, right - x), max(0.0, bottom - y))
return rl.Rectangle(
_snap(rect.x), _snap(rect.y),
_snap(rect.width), _snap(rect.height),
)
def _inset_rect(rect: rl.Rectangle, inset: float) -> rl.Rectangle:
@@ -136,11 +136,19 @@ def _draw_text_fit_common(
size = measure_text_cached(font, text, base_font_size, spacing=spacing)
actual_font_size = base_font_size
if size.x > max_width:
actual_font_size = max(1, int(round(font_size * (max_width / size.x))))
MIN_FONT = 8
hi = max(MIN_FONT, int(round(font_size * (max_width / size.x))))
lo = MIN_FONT
actual_font_size = hi
while lo < hi:
mid = (lo + hi + 1) // 2
fitted_spacing = round(requested_spacing * (mid / base_font_size))
if measure_text_cached(font, text, mid, spacing=fitted_spacing).x <= max_width:
lo = mid
actual_font_size = mid
else:
hi = mid - 1
fitted_spacing = round(requested_spacing * (actual_font_size / base_font_size))
while actual_font_size > 1 and measure_text_cached(font, text, actual_font_size, spacing=fitted_spacing).x > max_width:
actual_font_size -= 1
fitted_spacing = round(requested_spacing * (actual_font_size / base_font_size))
spacing = fitted_spacing
render_width = measure_text_cached(font, text, actual_font_size, spacing=spacing).x
else:
@@ -190,7 +198,7 @@ class AetherListColors:
SUCCESS_SOFT = rl.Color(94, 168, 130, 44)
WARNING = rl.Color(204, 158, 83, 255)
SCROLL_TRACK = rl.Color(255, 255, 255, 10)
SCROLL_THUMB = rl.Color(255, 255, 255, 68)
SCROLL_THUMB = rl.Color(255, 255, 255, 90)
@dataclass(frozen=True)
@@ -210,7 +218,7 @@ class AetherListMetrics:
row_radius: float = 0.12
action_width: int = 188
header_button_height: int = 58
header_button_gap: int = 10
header_button_gap: int = 10 # noqa: used implicitly by driving_model
fade_height: int = 24
content_right_gutter: int = 18
toggle_width: int = 78
@@ -223,10 +231,6 @@ class AetherListMetrics:
adjustor_scrubber_height: int = 52
adjustor_value_pill_height: int = 36
adjustor_value_pill_width: int = 144
range_row_height: int = 140
range_control_height: int = 56
range_control_bottom: int = 14
range_control_inset_x: int = 18
utility_value_right: int = 270
utility_value_width: int = 220
utility_chevron_right: int = 62
@@ -244,6 +248,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)
@dataclass(frozen=True, slots=True)
@@ -261,6 +266,9 @@ class PanelStyle:
divider_color: rl.Color
underline_color: rl.Color
accent: rl.Color
danger_fill: rl.Color
danger_border: rl.Color
danger_text: rl.Color
DEFAULT_PANEL_STYLE = PanelStyle(
@@ -277,6 +285,9 @@ DEFAULT_PANEL_STYLE = PanelStyle(
divider_color=rl.Color(255, 255, 255, 14),
underline_color=rl.Color(116, 136, 168, 150),
accent=AetherListColors.PRIMARY,
danger_fill=AetherListColors.DANGER_SOFT,
danger_border=rl.Color(173, 78, 90, 50),
danger_text=AetherListColors.DANGER,
)
@@ -373,6 +384,71 @@ def resolve_interactive_target(mouse_pos: MousePos, interactive_rects: dict[str,
return None
class AetherInteractiveMixin:
"""Standard interactive target system for list-panel ManagerViews.
Provides centralized _interactive_rects management, _pressed_target tracking,
touch validation via _scroll_panel.is_touch_valid(), and standard mouse
handling dispatch.
Subclasses MUST define:
- self._scroll_rect (rl.Rectangle) — for parent clipping in hit tests
- self._scroll_panel (GuiScrollPanel2) — for touch validation
Subclasses SHOULD override:
- _activate_target(target_id) — custom action dispatch
- _target_at(mouse_pos) — custom hit target resolution
"""
def __init__(self, *args, **kwargs):
self._interactive_rects: dict[str, rl.Rectangle] = {}
self._pressed_target: str | None = None
self._can_click = True
super().__init__(*args, **kwargs)
def _interactive_state(self, target_id: str, rect: rl.Rectangle, *, pad_y: float = 0) -> tuple[bool, bool]:
self._interactive_rects[target_id] = rect
hovered = _point_hits(gui_app.last_mouse_event.pos, rect, self._scroll_rect, pad_x=6, pad_y=pad_y)
return hovered, self._pressed_target == target_id
def _target_at(self, mouse_pos: MousePos) -> str | None:
for target_id, rect in self._interactive_rects.items():
if _point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0):
return target_id
return None
def _activate_target(self, target_id: str | None):
pass
def _handle_mouse_press(self, mouse_pos: MousePos):
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():
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
def _handle_mouse_release(self, mouse_pos: MousePos):
target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None
if self._pressed_target is not None and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
self._can_click = True
def show_event(self):
super().show_event()
self._pressed_target = None
self._can_click = True
def hide_event(self):
super().hide_event()
self._pressed_target = None
self._can_click = True
PANEL_HEADER_TITLE_Y: int = 4
PANEL_HEADER_SUBTITLE_Y: int = 48
PANEL_HEADER_TITLE_FONT_SIZE: int = 40
@@ -499,6 +575,34 @@ def draw_busy_ring(
rl.draw_ring(center, inner_radius, outer_radius, phase, phase + sweep, thickness, accent_color)
def draw_standard_toggle_row(
rect: rl.Rectangle,
title: str,
subtitle: str,
toggle_value: bool,
*,
enabled: bool = True,
hovered: bool = False,
pressed: bool = False,
is_last: bool = False,
style: PanelStyle = DEFAULT_PANEL_STYLE,
):
"""Draw a standard toggle settings row with consistent sizing."""
draw_settings_list_row(
rect,
title=title,
subtitle=subtitle,
toggle_value=toggle_value,
enabled=enabled,
hovered=hovered,
pressed=pressed,
is_last=is_last,
show_chevron=False,
title_size=34, subtitle_size=22,
style=style,
)
def draw_toggle_switch(
rect: rl.Rectangle,
enabled: bool,
@@ -560,6 +664,43 @@ def draw_chevron_icon(rect: rl.Rectangle, color: rl.Color, *, thickness: float =
rl.draw_line_ex(rl.Vector2(left_x, bottom_y), rl.Vector2(right_x, center_y), thickness, color)
TAB_HEIGHT = 68
TAB_GAP = 10
TAB_BOTTOM_GAP = 18
def draw_tab_bar(
rect: rl.Rectangle,
tab_defs: list[dict],
active_tab_key: str,
interactive_fn: Callable,
*,
subtitle_fn: Callable[[str], str] | None = None,
style: PanelStyle = DEFAULT_PANEL_STYLE,
tab_height: int = TAB_HEIGHT,
tab_gap: int = TAB_GAP,
tab_bottom_gap: int = TAB_BOTTOM_GAP,
) -> float:
"""Draw tab cards and register interactive targets.
Returns the y position after the tab bar + bottom gap."""
if not tab_defs:
return rect.y
n = len(tab_defs)
tab_w = (rect.width - tab_gap * max(0, n - 1)) / max(1, n)
for i, tab in enumerate(tab_defs):
tab_rect = rl.Rectangle(rect.x + i * (tab_w + tab_gap), rect.y, tab_w, tab_height)
hovered, pressed = interactive_fn(f"tab:{tab['id']}", tab_rect, pad_y=4)
subtitle = subtitle_fn(tab["id"]) if subtitle_fn else tab.get("subtitle", "")
draw_tab_card(
tab_rect, tab["title"], subtitle,
current=active_tab_key == tab["id"],
hovered=hovered, pressed=pressed,
title_size=26, subtitle_size=17, show_underline=True, style=style,
)
return rect.y + tab_height + tab_bottom_gap
def draw_tab_card(
rect: rl.Rectangle,
title: str,
@@ -867,86 +1008,6 @@ def format_adjustor_value(value: float, *, step: float = 1.0, unit: str = "", la
return f"{value:.2f}".rstrip("0").rstrip(".") + unit
def draw_range_setting_row(
rect: rl.Rectangle,
*,
title: str,
subtitle: str = "",
value: str = "",
enabled: bool = True,
hovered: bool = False,
pressed: bool = False,
is_last: bool = False,
title_size: int = 24,
subtitle_size: int = 18,
value_size: int = 24,
separator_inset: int = 22,
control_height: int = AETHER_LIST_METRICS.range_control_height,
control_bottom: int = AETHER_LIST_METRICS.range_control_bottom,
control_inset_x: int = AETHER_LIST_METRICS.range_control_inset_x,
title_color: rl.Color | None = None,
subtitle_color: rl.Color | None = None,
value_color: rl.Color | None = None,
style: PanelStyle = DEFAULT_PANEL_STYLE,
) -> rl.Rectangle:
draw_rect = _snap_rect(rect)
resolved_title_color = title_color or (style.title_color if enabled else style.muted_color)
resolved_subtitle_color = subtitle_color or (style.subtitle_color if enabled else style.muted_color)
resolved_value_color = value_color or (style.title_color if enabled else style.muted_color)
value_reserved = min(240.0, draw_rect.width * 0.24) if value else 0.0
content_left = draw_rect.x + 24
content_right = draw_rect.x + draw_rect.width - 24
if value_reserved:
content_right -= value_reserved + 20
content_width = max(120.0, content_right - content_left)
draw_list_row_shell(
draw_rect,
hovered=hovered and enabled,
pressed=pressed and enabled,
is_last=is_last,
row_bg=rl.Color(255, 255, 255, 0),
row_border=rl.Color(255, 255, 255, 0),
row_separator=style.divider_color,
current_bg=rl.Color(255, 255, 255, 0),
current_border=rl.Color(255, 255, 255, 0),
separator_inset=separator_inset,
)
gui_label(
rl.Rectangle(content_left, draw_rect.y + 14, content_width, title_size + 6),
title,
title_size,
resolved_title_color,
FontWeight.MEDIUM,
)
if subtitle:
gui_label(
rl.Rectangle(content_left, draw_rect.y + 46, content_width, subtitle_size + 8),
subtitle,
subtitle_size,
resolved_subtitle_color,
FontWeight.NORMAL,
)
if value:
gui_label(
rl.Rectangle(draw_rect.x + draw_rect.width - value_reserved - 24, draw_rect.y + 18, value_reserved, value_size + 6),
value,
value_size,
resolved_value_color,
FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
)
return _snap_rect(
rl.Rectangle(
draw_rect.x + control_inset_x,
draw_rect.y + draw_rect.height - control_height - control_bottom,
draw_rect.width - control_inset_x * 2,
control_height,
)
)
class AetherInlineRangeControl(Widget):
def __init__(
@@ -1632,18 +1693,6 @@ def draw_overflow_dots(center: rl.Vector2, color: rl.Color):
rl.draw_circle(int(center.x + (i - 1) * gap), int(center.y), dot_r, color)
def draw_trash_icon(center: rl.Vector2, color: rl.Color):
bin_rect = rl.Rectangle(center.x - 12, center.y - 12, 24, 24)
lid_rect = rl.Rectangle(center.x - 14, center.y - 18, 28, 5)
handle_rect = rl.Rectangle(center.x - 4, center.y - 22, 8, 4)
rl.draw_rectangle_rounded(bin_rect, 0.2, 8, color)
rl.draw_rectangle_rounded(lid_rect, 0.5, 8, color)
rl.draw_rectangle_rounded(handle_rect, 0.5, 8, color)
stripe = _with_alpha(AetherListColors.PANEL_BG, 120)
rl.draw_line(int(center.x - 6), int(center.y - 8), int(center.x - 6), int(center.y + 8), stripe)
rl.draw_line(int(center.x), int(center.y - 8), int(center.x), int(center.y + 8), stripe)
rl.draw_line(int(center.x + 6), int(center.y - 8), int(center.x + 6), int(center.y + 8), stripe)
def draw_heart_icon(center: rl.Vector2, color: rl.Color):
rl.draw_circle(int(center.x - 5), int(center.y - 3), 7, color)
@@ -1789,6 +1838,311 @@ class AetherScrollbar:
rl.draw_rectangle_rounded(thumb_rect, 1.0, 12, self._thumb_color)
# ── SettingRow / SettingSection dataclasses ──
SECTION_GAP = AETHER_LIST_METRICS.section_gap
SECTION_HEADER_HEIGHT = AETHER_LIST_METRICS.section_header_height
SECTION_HEADER_GAP = AETHER_LIST_METRICS.section_header_gap
ROW_HEIGHT = AETHER_LIST_METRICS.row_height
@dataclass
class SettingRow:
id: str
type: str
title: str
subtitle: str = ""
visible: Callable[[], bool] | None = None
enabled: Callable[[], bool] | None = None
disabled_label: str = ""
get_state: Callable[[], bool] | None = None
set_state: Callable[[bool], None] | None = None
get_value: Callable[[], str] | None = None
on_click: Callable[[], object] | None = None
action_text: str = ""
action_danger: bool = False
navigate_to: str = ""
@dataclass
class SettingSection:
title: str
rows: list[SettingRow]
visible: Callable[[], bool] | None = None
tab_key: str = ""
column_pair: str = ""
row_height: int = ROW_HEIGHT
# ── AetherSettingsView — reusable list-panel ManagerView ──
class AetherSettingsView(AetherInteractiveMixin, Widget):
"""Reusable list-panel manager for toggle/value/action settings pages."""
TAB_HEIGHT = 68
TAB_GAP = 10
TAB_BOTTOM_GAP = 18
COLUMN_GAP = 22
TWO_COLUMN_BREAKPOINT = 1180
def __init__(self, controller, sections: list[SettingSection],
*, header_title: str = "", header_subtitle: str = "",
tab_defs: list[dict] | None = None,
panel_style=None, fade_height: float = AETHER_LIST_METRICS.fade_height,
metrics = COMPACT_PANEL_METRICS):
super().__init__()
self._panel_style = panel_style or DEFAULT_PANEL_STYLE
self._fade_height = fade_height
self._metrics = metrics
self._controller = controller
self._sections = sections
self._header_title = header_title
self._header_subtitle = header_subtitle
self._has_header = bool(header_title)
self._tab_defs = tab_defs
self._active_tab_key = tab_defs[0]["id"] if tab_defs else ""
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._scroll_rect = rl.Rectangle(0, 0, 0, 0)
def _find_row(self, target_id: str) -> SettingRow | None:
for section in self._active_sections():
for row in section.rows:
if f"{row.type}:{row.id}" == target_id:
return row
return None
def _activate_target(self, target_id: str | None):
if not target_id:
return
if target_id.startswith("tab:") and self._tab_defs:
self._active_tab_key = target_id[4:]
return
row = self._find_row(target_id)
if row is None:
return
if row.navigate_to:
self._controller._navigate_to(row.navigate_to)
elif row.on_click:
row.on_click()
elif row.type == "toggle" and row.set_state and row.get_state:
row.set_state(not row.get_state())
def _render(self, rect: rl.Rectangle):
self.set_rect(rect)
self._interactive_rects.clear()
frame, scroll_rect, content_width = init_list_panel(rect, self._panel_style, metrics=self._metrics)
self._scroll_rect = scroll_rect
if self._has_header:
self._draw_header(frame.header)
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(self._content_height, self._scroll_rect.height))
rl.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()
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,
AetherListColors.PANEL_BG, fade_height=self._fade_height)
def _draw_header(self, rect: rl.Rectangle):
title = tr(self._header_title) if self._header_title else ""
subtitle = tr(self._header_subtitle) if self._header_subtitle else ""
draw_settings_panel_header(rect, title, subtitle)
def _active_sections(self) -> list[SettingSection]:
if self._tab_defs and self._active_tab_key:
return [s for s in self._sections if s.tab_key == self._active_tab_key]
return self._sections
def _visible_rows(self, section: SettingSection) -> list[SettingRow]:
if section.visible is not None and not section.visible():
return []
return [row for row in section.rows if row.visible is None or row.visible()]
def _measure_content_height(self, width: float) -> float:
total = 0.0
if self._tab_defs:
total += self.TAB_HEIGHT + self.TAB_BOTTOM_GAP
active = self._active_sections()
i = 0
while i < len(active):
section = active[i]
visible_rows = self._visible_rows(section)
if not visible_rows:
i += 1
continue
row_h = len(visible_rows) * section.row_height
if section.column_pair and i + 1 < len(active) and active[i + 1].column_pair == section.column_pair and self._uses_two_columns(width):
right_rows = self._visible_rows(active[i + 1])
row_h = max(row_h, len(right_rows) * active[i + 1].row_height)
i += 2
total += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
total += row_h
total += SECTION_GAP
else:
i += 1
total += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP if section.title else 0.0
total += row_h
total += SECTION_GAP
return max(0.0, total - SECTION_GAP) if total > 0 else 0.0
def _draw_tabs(self, y: float, x: float, width: float) -> float:
return draw_tab_bar(
rl.Rectangle(x, y, width, self.TAB_HEIGHT),
self._tab_defs, self._active_tab_key, self._interactive_state,
style=self._panel_style,
)
def _uses_two_columns(self, width: float) -> bool:
return width >= self.TWO_COLUMN_BREAKPOINT
def _has_subsequent_visible(self, start_idx: int, sections: list[SettingSection]) -> bool:
for j in range(start_idx, len(sections)):
if self._visible_rows(sections[j]):
return True
return False
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
y = rect.y + self._scroll_offset
if self._tab_defs:
y = self._draw_tabs(y, rect.x, width)
active = self._active_sections()
has_visible = any(self._visible_rows(s) for s in active)
if not has_visible:
draw_empty_state_card(
rl.Rectangle(rect.x, y, width, rect.height - (y - rect.y)),
tr("No settings to display"),
tr("All options in this panel are hidden or unavailable."),
style=self._panel_style,
)
return
i = 0
while i < len(active):
section = active[i]
visible_rows = self._visible_rows(section)
if not visible_rows:
i += 1
continue
if section.column_pair and i + 1 < len(active) and active[i + 1].column_pair == section.column_pair and self._uses_two_columns(width):
right_section = active[i + 1]
right_rows = self._visible_rows(right_section)
col_w = (width - self.COLUMN_GAP) / 2
section_h = len(visible_rows) * section.row_height
right_h = len(right_rows) * right_section.row_height
group_h = max(section_h, right_h)
draw_section_header(
rl.Rectangle(rect.x, y, col_w, SECTION_HEADER_HEIGHT),
tr(section.title), style=self._panel_style,
)
draw_section_header(
rl.Rectangle(rect.x + col_w + self.COLUMN_GAP, y, col_w, SECTION_HEADER_HEIGHT),
tr(right_section.title), style=self._panel_style,
)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
left_group = rl.Rectangle(rect.x, y, col_w, section_h)
right_group = rl.Rectangle(rect.x + col_w + self.COLUMN_GAP, y, col_w, right_h)
draw_list_group_shell(left_group, style=self._panel_style)
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))
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
def _draw_section(self, y: float, x: float, width: float,
section: SettingSection, rows: list[SettingRow]) -> float:
if section.title:
draw_section_header(
rl.Rectangle(x, y, width, SECTION_HEADER_HEIGHT),
tr(section.title),
style=self._panel_style,
)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
group_rect = rl.Rectangle(x, y, width, len(rows) * section.row_height)
draw_list_group_shell(group_rect, style=self._panel_style)
for i, row in enumerate(rows):
row_rect = rl.Rectangle(x, y + i * section.row_height, width, section.row_height)
self._draw_row(row_rect, row, is_last=(i == len(rows) - 1))
return y + group_rect.height
def _draw_row(self, rect: rl.Rectangle, row: SettingRow, is_last: bool):
target_id = f"{row.type}:{row.id}"
hovered, pressed = self._interactive_state(target_id, rect)
enabled = row.enabled() if row.enabled is not None else True
subtitle = row.disabled_label if not enabled and row.disabled_label else row.subtitle
if row.type == "toggle":
toggle_value = row.get_state() if row.get_state else False
draw_standard_toggle_row(
rect, tr(row.title), tr(subtitle), toggle_value,
enabled=enabled, hovered=hovered, pressed=pressed,
is_last=is_last, style=self._panel_style,
)
elif row.type == "value":
value_text = row.get_value() if row.get_value else ""
draw_settings_list_row(
rect,
title=tr(row.title),
subtitle=tr(subtitle),
value=value_text,
enabled=enabled,
hovered=hovered,
pressed=pressed,
is_last=is_last,
show_chevron=row.on_click is not None,
title_size=34, subtitle_size=22, value_size=28,
style=self._panel_style,
)
elif row.type == "action":
action_fill = self._panel_style.danger_fill if row.action_danger else self._panel_style.current_fill
action_border = self._panel_style.danger_border if row.action_danger else self._panel_style.current_border
action_text_color = self._panel_style.danger_text if row.action_danger else AetherListColors.HEADER
draw_selection_list_row(
rect,
title=tr(row.title),
subtitle=tr(subtitle),
action_text=tr(row.action_text),
hovered=hovered,
pressed=pressed,
is_last=is_last,
action_pill=True,
title_size=34, subtitle_size=22,
action_pill_height=44, action_text_size=18,
action_text_color=action_text_color,
action_fill=action_fill,
action_border=action_border,
row_separator=self._panel_style.divider_color,
)
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__()
@@ -3140,122 +3494,4 @@ def draw_toggle_pill(rect: rl.Rectangle, is_on: bool, is_enabled: bool, title: s
rl.draw_text_ex(font, status_str, rl.Vector2(round(status_x), round(rect.y + (rect.height - ts.y) / 2)), status_size, 0, AetherListColors.HEADER if is_enabled else AetherListColors.MUTED)
class AetherVerticalSlider(Widget):
"""Touch-first vertical slider for inline dashboard use.
Designed for automotive touch targets (60px+ wide tracks).
Renders: title above, vertical fill track, value label below."""
MIN_TRACK_WIDTH = 60 # Automotive touch minimum
def __init__(
self,
min_val: float,
max_val: float,
step: float,
current_val: float,
on_change: Callable[[float], None],
title: str = "",
unit: str = "",
labels: dict[float, str] | None = None,
color: rl.Color | None = None,
):
super().__init__()
self.min_val = min_val
self.max_val = max_val
self.base_step = step
self.current_val = current_val
self.on_change = on_change
self.title = title
self.unit = unit
self.labels = labels or {}
self.color = color or AetherListColors.PRIMARY
self._is_dragging = False
self._last_mouse_y = 0.0
self._smooth_value = current_val
self._track_rect = rl.Rectangle(0, 0, 0, 0)
self._font = gui_app.font(FontWeight.BOLD)
def _handle_mouse_press(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, self._rect):
self._is_dragging = True
self._last_mouse_y = mouse_pos.y
self._update_val_from_y(mouse_pos.y, self.base_step)
def _handle_mouse_release(self, mouse_pos: MousePos):
self._is_dragging = False
def _handle_mouse_event(self, mouse_event: MouseEvent):
if self._is_dragging:
dt = rl.get_frame_time()
dy = mouse_event.pos.y - self._last_mouse_y
self._last_mouse_y = mouse_event.pos.y
velocity = abs(dy / max(dt, 0.001))
if velocity > 1500:
step = self.base_step * 10
elif velocity > 500:
step = self.base_step * 5
else:
step = self.base_step
self._update_val_from_y(mouse_event.pos.y, step)
def _update_val_from_y(self, mouse_y: float, step: float):
tr_rect = self._track_rect
if tr_rect.height <= 0:
return
# Inverted: top = max, bottom = min
frac = 1.0 - max(0.0, min(1.0, (mouse_y - tr_rect.y) / tr_rect.height))
val = self.min_val + frac * (self.max_val - self.min_val)
if step <= 0:
snapped = max(self.min_val, min(self.max_val, val))
else:
snapped = round((val - self.min_val) / step) * step + self.min_val
snapped = max(self.min_val, min(self.max_val, snapped))
if snapped != self.current_val:
self.current_val = snapped
self.on_change(self.current_val)
def _render(self, rect: rl.Rectangle):
rect = _snap_rect(rect)
self.set_rect(rect)
dt = rl.get_frame_time()
self._smooth_value += (self.current_val - self._smooth_value) * (1 - math.exp(-dt / 0.060))
title_h = max(16, min(20, int(rect.height * 0.12)))
value_h = max(16, min(20, int(rect.height * 0.12)))
gap = max(4, min(6, int(rect.height * 0.03)))
track_top = rect.y + title_h + gap
track_h = rect.height - title_h - gap - value_h - gap
available_track_w = max(24.0, rect.width - 16)
track_w = min(available_track_w, max(36.0, min(rect.width * 0.7, float(self.MIN_TRACK_WIDTH))))
track_x = rect.x + (rect.width - track_w) / 2
self._track_rect = _snap_rect(rl.Rectangle(track_x, track_top, track_w, track_h))
title_size = max(14, min(18, int(rect.width * 0.16)))
value_size = max(14, min(18, int(rect.width * 0.16)))
ts = measure_text_cached(self._font, self.title, title_size)
tx = rect.x + (rect.width - ts.x) / 2
rl.draw_text_ex(self._font, self.title, rl.Vector2(round(tx), round(rect.y)), title_size, 0, AetherListColors.SUBTEXT)
_draw_rounded_fill(self._track_rect, rl.Color(34, 38, 48, 255), radius_px=16)
_draw_rounded_stroke(self._track_rect, rl.Color(255, 255, 255, 18), radius_px=16)
value_range = self.max_val - self.min_val
frac = 0.0 if value_range == 0 else max(0.0, min(1.0, (self._smooth_value - self.min_val) / value_range))
fill_h = frac * track_h
if fill_h > 1:
fill_rect = _snap_rect(rl.Rectangle(track_x, track_top + track_h - fill_h, track_w, fill_h))
rl.draw_rectangle_rec(fill_rect, _with_alpha(self.color, 192))
if 2 < fill_h < track_h - 2:
edge_y = track_top + track_h - fill_h
rl.draw_rectangle_rec(_snap_rect(rl.Rectangle(track_x + 6, edge_y - 1, track_w - 12, 2)), rl.Color(255, 255, 255, 96))
if self._is_dragging:
_draw_rounded_stroke(self._track_rect, _with_alpha(self.color, 88), thickness=2, radius_px=16)
val_str = self.labels.get(self.current_val, f"{self.current_val:.1f}{self.unit}" if isinstance(self.current_val, float) and self.base_step < 1 else f"{int(self.current_val)}{self.unit}")
vs = measure_text_cached(self._font, val_str, value_size)
vx = rect.x + (rect.width - vs.x) / 2
vy = track_top + track_h + gap
rl.draw_text_ex(self._font, val_str, rl.Vector2(round(vx), round(vy)), value_size, 0, rl.WHITE)
@@ -15,7 +15,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AetherSliderDialog,
panel_style_from_color,
)
from openpilot.selfdrive.ui.layouts.settings.starpilot.longitudinal import (
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SettingRow,
SettingSection,
AetherSettingsView,
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from collections.abc import Callable
import json
import math
@@ -34,6 +34,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPag
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
AetherButton,
AetherInteractiveMixin,
AetherChip,
AetherListColors,
AetherScrollbar,
@@ -70,6 +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=210)
CONFIRM_TIMEOUT_SECONDS = 3.0
TRANSITION_SECONDS = 0.24
PANEL_STYLE = panel_style_from_color("#3B82F6")
@@ -98,7 +100,7 @@ def _ease(current: float, target: float, tau: float = 0.085) -> float:
return current + (target - current) * (1 - math.exp(-dt / tau))
class DrivingModelManagerView(Widget):
class DrivingModelManagerView(AetherInteractiveMixin, Widget):
def __init__(self, controller: "StarPilotDrivingModelLayout"):
super().__init__()
self._controller = controller
@@ -106,9 +108,6 @@ class DrivingModelManagerView(Widget):
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._pressed_target: str | None = None
self._can_click = True
self._interactive_rects: dict[str, rl.Rectangle] = {}
self._confirm_key: str | None = None
self._confirm_until = 0.0
self._transition_starts: dict[str, tuple[float, float]] = {}
@@ -194,24 +193,6 @@ class DrivingModelManagerView(Widget):
if time.monotonic() - started_at >= TRANSITION_SECONDS:
self._transition_starts.pop(key, None)
def _handle_mouse_press(self, mouse_pos: MousePos):
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():
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
def _handle_mouse_release(self, mouse_pos: MousePos):
target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None
if self._pressed_target is not None and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
self._can_click = True
def _target_at(self, mouse_pos: MousePos) -> str | None:
for prefix in ("menu:", "action:", "utility:", "row:"):
for target_id, rect in self._interactive_rects.items():
@@ -280,7 +261,7 @@ class DrivingModelManagerView(Widget):
self.set_rect(rect)
self._interactive_rects.clear()
frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE)
frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE, metrics=DRIVING_MODEL_METRICS)
self._shell_rect = frame.shell
self._scroll_rect = scroll_rect
@@ -8,8 +8,8 @@ from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage
from openpilot.selfdrive.ui.layouts.settings.starpilot.longitudinal import (
SettingRow, SettingSection, AetherSettingsView,
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SettingRow, SettingSection, AetherSettingsView, COMPACT_PANEL_METRICS,
)
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
@@ -118,6 +118,7 @@ class StarPilotAdvancedLateralLayout(_SettingsPage):
header_title=tr_noop("Advanced Lateral Tuning"),
header_subtitle=tr_noop("Adjust steering response, torque controller behavior, and auto-tuning controls."),
panel_style=PANEL_STYLE,
metrics=COMPACT_PANEL_METRICS,
)
@@ -255,6 +256,7 @@ class StarPilotLateralLayout(_SettingsPage):
header_subtitle=tr_noop("Fine-tune lateral control, lane changes, and steering behavior."),
tab_defs=tab_defs,
panel_style=PANEL_STYLE,
metrics=COMPACT_PANEL_METRICS,
)
def _show_modal_pace_selector(self):
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from collections.abc import Callable
import pyray as rl
@@ -18,6 +18,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPan
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
AetherInteractiveMixin,
AetherListColors,
AetherScrollbar,
AetherSliderDialog,
@@ -29,7 +30,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
draw_selection_list_row,
draw_settings_list_row,
draw_settings_panel_header,
draw_tab_card,
draw_tab_bar,
init_list_panel,
)
@@ -43,6 +44,7 @@ from openpilot.starpilot.common.experimental_state import sync_persist_experimen
PANEL_STYLE = panel_style_from_color("#3B82F6")
COMPACT_PANEL_METRICS = replace(AETHER_LIST_METRICS, header_height=96) # noqa: re-exported
SECTION_GAP = AETHER_LIST_METRICS.section_gap
SECTION_HEADER_HEIGHT = AETHER_LIST_METRICS.section_header_height
SECTION_HEADER_GAP = AETHER_LIST_METRICS.section_header_gap
@@ -62,349 +64,10 @@ DECELERATION_PROFILE_OPTIONS = [
]
@dataclass
class SettingRow:
id: str
type: str
title: str
subtitle: str = ""
visible: Callable[[], bool] | None = None
enabled: Callable[[], bool] | None = None
disabled_label: str = ""
get_state: Callable[[], bool] | None = None
set_state: Callable[[bool], None] | None = None
get_value: Callable[[], str] | None = None
on_click: Callable[[], object] | None = None
action_text: str = ""
action_danger: bool = False
navigate_to: str = ""
@dataclass
class SettingSection:
title: str
rows: list[SettingRow]
visible: Callable[[], bool] | None = None
tab_key: str = ""
column_pair: str = ""
row_height: int = ROW_HEIGHT
class AetherSettingsView(Widget):
"""Reusable list-panel manager for toggle/value/action settings pages."""
TAB_HEIGHT = 56
TAB_GAP = 10
TAB_BOTTOM_GAP = 18
COLUMN_GAP = 22
def __init__(self, controller: StarPilotPanel, sections: list[SettingSection],
*, header_title: str = "", header_subtitle: str = "",
tab_defs: list[dict] | None = None,
panel_style=None, fade_height: float = AETHER_LIST_METRICS.fade_height):
super().__init__()
self._panel_style = panel_style or PANEL_STYLE
self._fade_height = fade_height
self._controller = controller
self._sections = sections
self._header_title = header_title
self._header_subtitle = header_subtitle
self._has_header = bool(header_title)
self._tab_defs = tab_defs
self._active_tab_key = tab_defs[0]["id"] if tab_defs else ""
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._interactive_rects: dict[str, rl.Rectangle] = {}
self._pressed_target: str | None = None
self._can_click = True
self._scroll_rect = rl.Rectangle(0, 0, 0, 0)
def _interactive_state(self, target_id: str, rect: rl.Rectangle, *, pad_y: int = 0) -> tuple[bool, bool]:
self._interactive_rects[target_id] = rect
hovered = _point_hits(gui_app.last_mouse_event.pos, rect, self._scroll_rect, pad_x=6, pad_y=pad_y)
return hovered, self._pressed_target == target_id
def _target_at(self, mouse_pos: MousePos) -> str | None:
for tid, r in self._interactive_rects.items():
if _point_hits(mouse_pos, r, self._scroll_rect, pad_x=6, pad_y=0):
return tid
return None
def _find_row(self, target_id: str) -> SettingRow | None:
for section in self._active_sections():
for row in section.rows:
if f"{row.type}:{row.id}" == target_id:
return row
return None
def _activate_target(self, target_id: str | None):
if not target_id:
return
if target_id.startswith("tab:") and self._tab_defs:
self._active_tab_key = target_id[4:]
return
row = self._find_row(target_id)
if row is None:
return
if row.navigate_to:
self._controller._navigate_to(row.navigate_to)
elif row.on_click:
row.on_click()
elif row.type == "toggle" and row.set_state and row.get_state:
row.set_state(not row.get_state())
def _handle_mouse_press(self, pos: MousePos):
self._pressed_target = self._target_at(pos)
self._can_click = True
def _handle_mouse_event(self, ev: MouseEvent):
if not self._scroll_panel.is_touch_valid():
self._can_click = False
return
if self._pressed_target and self._target_at(ev.pos) != self._pressed_target:
self._pressed_target = None
def _handle_mouse_release(self, pos: MousePos):
target = self._target_at(pos) if self._scroll_panel.is_touch_valid() else None
if self._pressed_target and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
self._can_click = True
def show_event(self):
super().show_event()
self._pressed_target = None
self._can_click = True
def hide_event(self):
super().hide_event()
self._pressed_target = None
self._can_click = True
def _render(self, rect: rl.Rectangle):
self.set_rect(rect)
self._interactive_rects.clear()
frame, scroll_rect, content_width = init_list_panel(rect, self._panel_style)
self._scroll_rect = scroll_rect
if self._has_header:
self._draw_header(frame.header)
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(self._content_height, self._scroll_rect.height))
rl.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()
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,
AetherListColors.PANEL_BG, fade_height=self._fade_height)
def _draw_header(self, rect: rl.Rectangle):
title = tr(self._header_title) if self._header_title else ""
subtitle = tr(self._header_subtitle) if self._header_subtitle else ""
draw_settings_panel_header(rect, title, subtitle)
def _active_sections(self) -> list[SettingSection]:
if self._tab_defs and self._active_tab_key:
return [s for s in self._sections if s.tab_key == self._active_tab_key]
return self._sections
def _visible_rows(self, section: SettingSection) -> list[SettingRow]:
if section.visible is not None and not section.visible():
return []
return [row for row in section.rows if row.visible is None or row.visible()]
def _measure_content_height(self, width: float) -> float:
total = 0.0
if self._tab_defs:
total += self.TAB_HEIGHT + self.TAB_BOTTOM_GAP
active = self._active_sections()
i = 0
while i < len(active):
section = active[i]
visible_rows = self._visible_rows(section)
if not visible_rows:
i += 1
continue
row_h = len(visible_rows) * section.row_height
if section.column_pair and i + 1 < len(active) and active[i + 1].column_pair == section.column_pair:
right_rows = self._visible_rows(active[i + 1])
row_h = max(row_h, len(right_rows) * active[i + 1].row_height)
i += 2
else:
i += 1
total += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP if section.title else 0.0
total += row_h
total += SECTION_GAP
return max(0.0, total - SECTION_GAP) if total > 0 else 0.0
def _draw_tabs(self, y: float, x: float, width: float) -> float:
if not self._tab_defs:
return y
n = len(self._tab_defs)
tab_w = (width - self.TAB_GAP * max(0, n - 1)) / max(1, n)
for i, tab in enumerate(self._tab_defs):
tab_rect = rl.Rectangle(x + i * (tab_w + self.TAB_GAP), y, tab_w, self.TAB_HEIGHT)
target_id = f"tab:{tab['id']}"
hovered, pressed = self._interactive_state(target_id, tab_rect, pad_y=4)
draw_tab_card(
tab_rect,
tab["title"],
tab.get("subtitle", ""),
current=self._active_tab_key == tab["id"],
hovered=hovered,
pressed=pressed,
title_size=26,
subtitle_size=17,
show_underline=True,
style=self._panel_style,
)
return y + self.TAB_HEIGHT + self.TAB_BOTTOM_GAP
def _has_subsequent_visible(self, start_idx: int, sections: list[SettingSection]) -> bool:
for j in range(start_idx, len(sections)):
if self._visible_rows(sections[j]):
return True
return False
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
y = rect.y + self._scroll_offset
if self._tab_defs:
y = self._draw_tabs(y, rect.x, width)
active = self._active_sections()
i = 0
while i < len(active):
section = active[i]
visible_rows = self._visible_rows(section)
if not visible_rows:
i += 1
continue
if section.column_pair and i + 1 < len(active) and active[i + 1].column_pair == section.column_pair:
right_section = active[i + 1]
right_rows = self._visible_rows(right_section)
col_w = (width - self.COLUMN_GAP) / 2
section_h = len(visible_rows) * section.row_height
right_h = len(right_rows) * right_section.row_height
group_h = max(section_h, right_h)
draw_section_header(
rl.Rectangle(rect.x, y, col_w, SECTION_HEADER_HEIGHT),
tr(section.title), style=self._panel_style,
)
draw_section_header(
rl.Rectangle(rect.x + col_w + self.COLUMN_GAP, y, col_w, SECTION_HEADER_HEIGHT),
tr(right_section.title), style=self._panel_style,
)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
left_group = rl.Rectangle(rect.x, y, col_w, section_h)
right_group = rl.Rectangle(rect.x + col_w + self.COLUMN_GAP, y, col_w, right_h)
draw_list_group_shell(left_group, style=self._panel_style)
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))
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
def _draw_section(self, y: float, x: float, width: float,
section: SettingSection, rows: list[SettingRow]) -> float:
if section.title:
draw_section_header(
rl.Rectangle(x, y, width, SECTION_HEADER_HEIGHT),
tr(section.title),
style=self._panel_style,
)
y += SECTION_HEADER_HEIGHT + SECTION_HEADER_GAP
group_rect = rl.Rectangle(x, y, width, len(rows) * section.row_height)
draw_list_group_shell(group_rect, style=self._panel_style)
for i, row in enumerate(rows):
row_rect = rl.Rectangle(x, y + i * section.row_height, width, section.row_height)
self._draw_row(row_rect, row, is_last=(i == len(rows) - 1))
return y + group_rect.height
def _draw_row(self, rect: rl.Rectangle, row: SettingRow, is_last: bool):
target_id = f"{row.type}:{row.id}"
hovered, pressed = self._interactive_state(target_id, rect)
enabled = row.enabled() if row.enabled is not None else True
subtitle = row.disabled_label if not enabled and row.disabled_label else row.subtitle
if row.type == "toggle":
toggle_value = row.get_state() if row.get_state else False
draw_settings_list_row(
rect,
title=tr(row.title),
subtitle=tr(subtitle),
toggle_value=toggle_value,
enabled=enabled,
hovered=hovered,
pressed=pressed,
is_last=is_last,
show_chevron=False,
title_size=34, subtitle_size=22,
style=self._panel_style,
)
elif row.type == "value":
value_text = row.get_value() if row.get_value else ""
draw_settings_list_row(
rect,
title=tr(row.title),
subtitle=tr(subtitle),
value=value_text,
enabled=enabled,
hovered=hovered,
pressed=pressed,
is_last=is_last,
show_chevron=row.on_click is not None,
title_size=34, subtitle_size=22, value_size=28,
style=self._panel_style,
)
elif row.type == "action":
action_fill = AetherListColors.DANGER_SOFT if row.action_danger else self._panel_style.current_fill
action_border = (rl.Color(AetherListColors.DANGER.r, AetherListColors.DANGER.g,
AetherListColors.DANGER.b, 70)
if row.action_danger else self._panel_style.current_border)
action_text_color = AetherListColors.DANGER if row.action_danger else AetherListColors.HEADER
draw_selection_list_row(
rect,
title=tr(row.title),
subtitle=tr(subtitle),
action_text=tr(row.action_text),
hovered=hovered,
pressed=pressed,
is_last=is_last,
action_pill=True,
title_size=34, subtitle_size=22,
action_pill_height=44, action_text_size=18,
action_text_color=action_text_color,
action_fill=action_fill,
action_border=action_border,
row_separator=self._panel_style.divider_color,
)
# Backward-compat re-exports (these symbols now live in aethergrid.py)
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SettingRow, SettingSection, AetherSettingsView,
)
# ═══════════════════════════════════════════════════════════════
@@ -75,7 +75,7 @@ BROWSER_SECTION_HEADER_HEIGHT = AETHER_LIST_METRICS.section_header_height
BROWSER_SECTION_HEADER_GAP = AETHER_LIST_METRICS.section_header_gap
BROWSER_INSET = AETHER_LIST_METRICS.content_right_gutter
BROWSER_TAB_GAP = AETHER_LIST_METRICS.section_header_gap
BROWSER_CONTEXT_TAB_HEIGHT = 56
BROWSER_CONTEXT_TAB_HEIGHT = 68
BROWSER_REGION_ROW_HEIGHT = AETHER_LIST_METRICS.row_height
BROWSER_EMPTY_STATE_HEIGHT = 128
STATUS_CARD_INSET = BROWSER_INSET
@@ -323,6 +323,8 @@ class _SettingsPage(StarPilotPanel):
self._sub_panels[self._current_sub_panel].render(rect)
elif self._manager_view is not None:
self._manager_view.render(rect)
else:
super()._render(rect)
def show_event(self):
super().show_event()
@@ -9,7 +9,9 @@ from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import SPACING, TileGrid, AetherScrollbar
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
SPACING, TileGrid, AetherScrollbar, AetherListColors, draw_list_scroll_fades,
)
@dataclass(frozen=True)
@@ -19,7 +21,7 @@ class TileSection:
class SectionedTileLayout(Widget):
def __init__(self, section_gap: int = SPACING.section_gap, title_height: int = 32, title_gap: int = SPACING.sm,
def __init__(self, section_gap: int = 28, title_height: int = 34, title_gap: int = 12,
min_row_height: int = 150, max_row_height: int = 280, top_padding: int = 0,
horizontal_padding: int = SPACING.xl, max_content_width: int | None = 1440):
super().__init__()
@@ -169,3 +171,5 @@ class SectionedTileLayout(Widget):
if self._content_height > content_rect.height:
self._scrollbar.render(content_rect, self._content_height, self._scroll_offset)
draw_list_scroll_fades(content_rect, self._content_height, self._scroll_offset,
AetherListColors.PANEL_BG)
@@ -8,16 +8,20 @@ from openpilot.common.basedir import BASEDIR
from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos
from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
AetherAdjustorRow,
AetherListColors,
AetherScrollbar,
panel_style_from_color,
_point_hits,
draw_list_scroll_fades,
draw_section_header,
draw_settings_panel_header,
draw_toggle_pill,
init_list_panel,
@@ -37,8 +41,22 @@ class SoundsManagerView(Widget):
self._was_interacting: dict[str, bool] = {}
self._toggle_rects: dict[str, rl.Rectangle] = {}
self._active_adjustor_key: str | None = None
self._can_click = True
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._scroll_rect = rl.Rectangle(0, 0, 0, 0)
self._init_adjustors()
self._forward_touch_valid()
def _forward_touch_valid(self):
for adjustor in self._adjustor_rows.values():
adjustor.set_touch_valid_callback(
lambda: self._scroll_panel.is_touch_valid() or adjustor.is_interacting
)
def _set_active_adjustor(self, key: str, active: bool):
if active:
@@ -100,6 +118,7 @@ class SoundsManagerView(Widget):
def _handle_mouse_press(self, mouse_pos: MousePos):
self._pressed_target = self._target_at(mouse_pos)
self._can_click = True
for adjustor in self._adjustor_rows.values():
adjustor._handle_mouse_press(mouse_pos)
@@ -107,18 +126,22 @@ class SoundsManagerView(Widget):
for adjustor in self._adjustor_rows.values():
adjustor._handle_mouse_release(mouse_pos)
target = self._target_at(mouse_pos)
if self._pressed_target is not None and self._pressed_target == target:
target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None
if self._pressed_target is not None and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
self._can_click = True
def _handle_mouse_event(self, mouse_event: MouseEvent):
if not self._scroll_panel.is_touch_valid():
self._can_click = False
return
for adjustor in self._adjustor_rows.values():
adjustor._handle_mouse_event(mouse_event)
def _target_at(self, mouse_pos: MousePos) -> str | None:
for key, rect in self._toggle_rects.items():
if _point_hits(mouse_pos, rect, pad_x=6, pad_y=6):
if _point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0):
return f"toggle:{key}"
return None
@@ -130,25 +153,43 @@ class SoundsManagerView(Widget):
current = self._controller._params.get_bool(key)
self._controller._params.put_bool(key, not current)
def show_event(self):
super().show_event()
self._pressed_target = None
self._can_click = True
def hide_event(self):
super().hide_event()
self._pressed_target = None
self._can_click = True
def _render(self, rect: rl.Rectangle):
self.set_rect(rect)
self._toggle_rects.clear()
frame, _scroll_rect, _content_width = init_list_panel(rect, PANEL_STYLE)
frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE)
self._scroll_rect = scroll_rect
self._draw_header(frame.header)
metrics = AETHER_LIST_METRICS
actual_header_height = 100
content_y = frame.header.y + actual_header_height
content_h = (frame.shell.y + frame.shell.height) - content_y - metrics.panel_padding_bottom
self._content_height = self._measure_content_height(content_width)
col_width = (frame.scroll.width - SECTION_GAP) / 2
col_left = rl.Rectangle(frame.scroll.x, content_y, col_width, content_h)
col_right = rl.Rectangle(frame.scroll.x + col_width + SECTION_GAP, content_y, col_width, content_h)
self._scroll_panel.set_enabled(self.is_visible)
self._scroll_offset = self._scroll_panel.update(
scroll_rect, max(self._content_height, scroll_rect.height)
)
self._draw_volume_column(col_left)
self._draw_utility_column(col_right)
rl.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()
if self._content_height > scroll_rect.height:
self._scrollbar.render(scroll_rect, self._content_height, self._scroll_offset)
draw_list_scroll_fades(scroll_rect, self._content_height, self._scroll_offset,
AetherListColors.PANEL_BG)
for key in self._controller.VOLUME_KEYS:
adjustor = self._adjustor_rows[key]
@@ -160,9 +201,38 @@ class SoundsManagerView(Widget):
self._controller._test_sound(key)
self._was_interacting[key] = is_interacting
def _measure_content_height(self, content_width: float) -> float:
col_width = (content_width - SECTION_GAP) / 2
left_h = 0.0
for key in self._controller.VOLUME_KEYS:
left_h += self._adjustor_rows[key].measure_height(col_width)
right_h = self._adjustor_rows[self._controller.COOLDOWN_KEY].measure_height(col_width)
right_h += AETHER_LIST_METRICS.utility_row_height * len(self._controller.CUSTOM_ALERTS_KEYS)
return max(left_h, right_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)
def _draw_scroll_content(self, rect: rl.Rectangle, content_width: float):
y = rect.y + self._scroll_offset
col_width = (content_width - SECTION_GAP) / 2
draw_section_header(
rl.Rectangle(rect.x, y, col_width, AETHER_LIST_METRICS.section_header_height),
tr("Volume"), style=PANEL_STYLE
)
draw_section_header(
rl.Rectangle(rect.x + col_width + SECTION_GAP, y, col_width, AETHER_LIST_METRICS.section_header_height),
tr("Alerts"), style=PANEL_STYLE
)
y += AETHER_LIST_METRICS.section_header_height + AETHER_LIST_METRICS.section_header_gap
col_left = rl.Rectangle(rect.x, y, col_width, self._content_height)
col_right = rl.Rectangle(rect.x + col_width + SECTION_GAP, y, col_width, self._content_height)
self._draw_volume_column(col_left)
self._draw_utility_column(col_right)
def _draw_volume_column(self, rect: rl.Rectangle):
current_y = rect.y
for key in self._controller.VOLUME_KEYS:
@@ -170,7 +240,7 @@ class SoundsManagerView(Widget):
row_h = adjustor.measure_height(rect.width)
row_rect = rl.Rectangle(rect.x, current_y, rect.width, row_h)
adjustor.set_is_last(True)
adjustor.set_parent_rect(rect)
adjustor.set_parent_rect(self._scroll_rect)
adjustor.render(row_rect)
current_y += row_h
@@ -181,7 +251,7 @@ class SoundsManagerView(Widget):
adjustor = self._adjustor_rows[cd_key]
row_h = adjustor.measure_height(rect.width)
adjustor.set_is_last(True)
adjustor.set_parent_rect(rect)
adjustor.set_parent_rect(self._scroll_rect)
adjustor.render(rl.Rectangle(rect.x, current_y, rect.width, row_h))
current_y += row_h
@@ -198,7 +268,7 @@ class SoundsManagerView(Widget):
is_enabled = info.get("is_enabled", lambda: True)()
mouse_pos = gui_app.last_mouse_event.pos
hovered = _point_hits(mouse_pos, padded_rect, pad_x=6, pad_y=6)
hovered = _point_hits(mouse_pos, padded_rect, self._scroll_rect, pad_x=6, pad_y=0)
pressed = self._pressed_target == f"toggle:{key}"
status_str = tr("ON") if current_val else tr("OFF")
@@ -209,7 +279,7 @@ class SoundsManagerView(Widget):
self._toggle_rects[key] = padded_rect
class StarPilotSoundsLayout(StarPilotPanel):
class StarPilotSoundsLayout(_SettingsPage):
COOLDOWN_KEY = "SwitchbackModeCooldown"
VOLUME_KEYS = [
"BelowSteerSpeedVolume",
@@ -269,15 +339,6 @@ class StarPilotSoundsLayout(StarPilotPanel):
self._manager_view = SoundsManagerView(self)
def _render(self, rect: rl.Rectangle):
self._manager_view.render(rect)
def show_event(self):
super().show_event()
def hide_event(self):
super().hide_event()
@classmethod
def _init_sound_player(cls):
if cls._sound_player_process is not None and cls._sound_player_process.poll() is None: return
@@ -26,6 +26,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPag
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
AetherAdjustorRow,
AetherInteractiveMixin,
AetherScrollbar,
AetherSegmentedControl,
AetherListColors,
@@ -36,10 +37,10 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
draw_list_scroll_fades,
draw_section_header,
draw_selection_list_row,
draw_settings_list_row,
draw_standard_toggle_row,
draw_settings_panel_header,
draw_soft_card,
draw_tab_card,
draw_tab_bar,
)
from openpilot.starpilot.common.connect_server import prepare_konik_server_switch
@@ -85,11 +86,11 @@ FADE_HEIGHT = AETHER_LIST_METRICS.fade_height
PANEL_STYLE = panel_style_from_color("#D946EF")
class SystemSettingsManagerView(Widget):
class SystemSettingsManagerView(AetherInteractiveMixin, Widget):
HEADER_SUBTITLE_HEIGHT = 24
HEADER_SUMMARY_GAP = 12
HEADER_CARD_HEIGHT = 108
TAB_HEIGHT = 56
TAB_HEIGHT = 68
TAB_GAP = 10
TAB_BOTTOM_GAP = 18
COLUMN_GAP = 22
@@ -105,9 +106,6 @@ class SystemSettingsManagerView(Widget):
self._content_height = 0.0
self._scroll_offset = 0.0
self._ensure_visible_key: str | None = None
self._interactive_rects: dict[str, rl.Rectangle] = {}
self._pressed_target: str | None = None
self._can_click = True
self._active_tab_key = "basics"
self._active_adjustor_key: str | None = None
self._adjustor_rows: dict[str, AetherAdjustorRow] = {}
@@ -371,11 +369,6 @@ class SystemSettingsManagerView(Widget):
def _column_width(self, width: float) -> float:
return (width - self.COLUMN_GAP) / 2 if self._uses_two_columns(width) else width
def _interactive_state(self, target_id: str, rect: rl.Rectangle, *, pad_y: float = 0) -> tuple[bool, bool]:
self._interactive_rects[target_id] = rect
hovered = _point_hits(gui_app.last_mouse_event.pos, rect, self._scroll_rect, pad_x=6, pad_y=pad_y)
return hovered, self._pressed_target == target_id
def _lookup_toggle(self, toggle_id: str):
return next((toggle for toggle in self._toggle_defs if toggle["id"] == toggle_id), None)
@@ -441,30 +434,6 @@ class SystemSettingsManagerView(Widget):
super().hide_event()
self._clear_ephemeral_state()
def _handle_mouse_press(self, mouse_pos: MousePos):
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():
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
def _handle_mouse_release(self, mouse_pos: MousePos):
target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None
if self._pressed_target is not None and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
self._can_click = True
def _target_at(self, mouse_pos: MousePos) -> str | None:
for target_id, rect in self._interactive_rects.items():
if _point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0):
return target_id
return None
def _activate_target(self, target_id: str | None):
if not target_id:
return
@@ -608,26 +577,10 @@ class SystemSettingsManagerView(Widget):
self._draw_care_tab(y, rect.x, width)
def _draw_tabs(self, rect: rl.Rectangle):
if not self._tab_defs:
return
available_w = max(1.0, rect.width)
tab_w = (available_w - self.TAB_GAP * max(0, len(self._tab_defs) - 1)) / max(1, len(self._tab_defs))
for index, tab in enumerate(self._tab_defs):
tab_rect = rl.Rectangle(rect.x + index * (tab_w + self.TAB_GAP), rect.y, tab_w, self.TAB_HEIGHT)
target_id = f"tab:{tab['id']}"
hovered, pressed = self._interactive_state(target_id, tab_rect, pad_y=4)
draw_tab_card(
tab_rect,
tab["title"],
self._tab_subtitle(tab["id"]),
current=self._active_tab_key == tab["id"],
hovered=hovered,
pressed=pressed,
title_size=26,
subtitle_size=17,
show_underline=True,
style=PANEL_STYLE,
)
draw_tab_bar(
rect, self._tab_defs, self._active_tab_key, self._interactive_state,
subtitle_fn=self._tab_subtitle, style=PANEL_STYLE,
)
def _draw_basics_tab(self, y: float, x: float, width: float):
if self._uses_two_columns(width):
@@ -706,19 +659,10 @@ class SystemSettingsManagerView(Widget):
hovered, pressed = self._interactive_state(target_id, rect)
is_enabled = toggle_def.get("is_enabled", lambda: True)()
subtitle = toggle_def.get("disabled_label", "") if not is_enabled and toggle_def.get("disabled_label") else toggle_def["subtitle"]
draw_settings_list_row(
rect,
title=toggle_def["title"],
subtitle=subtitle,
toggle_value=toggle_def["get"](),
enabled=is_enabled,
hovered=hovered,
pressed=pressed,
is_last=is_last,
show_chevron=False,
title_size=34,
subtitle_size=22,
style=PANEL_STYLE,
draw_standard_toggle_row(
rect, toggle_def["title"], subtitle, toggle_def["get"](),
enabled=is_enabled, hovered=hovered, pressed=pressed,
is_last=is_last, style=PANEL_STYLE,
)
def _draw_backups_section(self, y: float, x: float, width: float) -> float:
@@ -776,11 +720,11 @@ class SystemSettingsManagerView(Widget):
y += support_rect.height + 12
danger_title_rect = rl.Rectangle(x, y, width, 22)
gui_label(danger_title_rect, tr("Danger Zone"), 20, AetherListColors.DANGER, FontWeight.MEDIUM)
gui_label(danger_title_rect, tr("Danger Zone"), 20, PANEL_STYLE.danger_text, FontWeight.MEDIUM)
y += 30
danger_rect = rl.Rectangle(x, y, width, self._section_height(len(self._danger_rows), ROW_HEIGHT))
draw_list_group_shell(danger_rect, fill=rl.Color(173, 78, 90, 10), border=rl.Color(173, 78, 90, 30), style=PANEL_STYLE)
draw_list_group_shell(danger_rect, fill=PANEL_STYLE.danger_fill, border=PANEL_STYLE.danger_border, style=PANEL_STYLE)
for index, row in enumerate(self._danger_rows):
row_rect = rl.Rectangle(danger_rect.x, danger_rect.y + index * ROW_HEIGHT, danger_rect.width, ROW_HEIGHT)
self._draw_action_row(row_rect, row, is_last=index == len(self._danger_rows) - 1, danger=True)
@@ -788,9 +732,9 @@ class SystemSettingsManagerView(Widget):
def _draw_action_row(self, rect: rl.Rectangle, row: dict, is_last: bool, *, danger: bool = False):
target_id = f"action:{row['id']}"
hovered, pressed = self._interactive_state(target_id, rect)
action_fill = rl.Color(173, 78, 90, 22) if danger else rl.Color(255, 255, 255, 8)
action_border = rl.Color(173, 78, 90, 48) if danger else rl.Color(255, 255, 255, 24)
action_text_color = AetherListColors.HEADER if not danger else rl.Color(244, 214, 219, 255)
action_fill = PANEL_STYLE.danger_fill if danger else rl.Color(255, 255, 255, 8)
action_border = PANEL_STYLE.danger_border if danger else rl.Color(255, 255, 255, 24)
action_text_color = AetherListColors.HEADER if not danger else PANEL_STYLE.danger_text
draw_selection_list_row(
rect,
title=row["title"],
@@ -810,8 +754,8 @@ class SystemSettingsManagerView(Widget):
action_fill=action_fill,
action_border=action_border,
action_text_color=action_text_color,
title_color=AetherListColors.HEADER if not danger else rl.Color(249, 229, 233, 255),
subtitle_color=AetherListColors.SUBTEXT if not danger else rl.Color(203, 171, 178, 255),
title_color=AetherListColors.HEADER if not danger else PANEL_STYLE.danger_text,
subtitle_color=AetherListColors.SUBTEXT if not danger else PANEL_STYLE.danger_text,
)
class StarPilotSystemLayout(_SettingsPage):
@@ -13,6 +13,7 @@ from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_LIST_METRICS,
AetherInteractiveMixin,
AetherListColors,
AetherScrollbar,
AetherSliderDialog,
@@ -26,7 +27,7 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
draw_settings_list_row,
draw_settings_panel_header,
draw_soft_card,
draw_tab_card,
draw_tab_bar,
init_list_panel,
)
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
@@ -69,11 +70,11 @@ FADE_HEIGHT = AETHER_LIST_METRICS.fade_height
PANEL_STYLE = panel_style_from_color("#64748B")
class VehicleSettingsManagerView(Widget):
class VehicleSettingsManagerView(AetherInteractiveMixin, Widget):
HEADER_SUBTITLE_HEIGHT = 24
HEADER_SUMMARY_GAP = 12
HEADER_CARD_HEIGHT = 108
TAB_HEIGHT = 56
TAB_HEIGHT = 68
TAB_GAP = 10
TAB_BOTTOM_GAP = 18
TWO_COLUMN_BREAKPOINT = 1180
@@ -86,9 +87,6 @@ class VehicleSettingsManagerView(Widget):
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._interactive_rects: dict[str, rl.Rectangle] = {}
self._pressed_target: str | None = None
self._can_click = True
self._active_tab_key = "identity"
self._shell_rect = rl.Rectangle(0, 0, 0, 0)
self._scroll_rect = rl.Rectangle(0, 0, 0, 0)
@@ -116,48 +114,7 @@ class VehicleSettingsManagerView(Widget):
def _stacked_section_height(self, sections: list[float]) -> float:
if not sections:
return 0.0
return max(0.0, sum(sections) - SECTION_GAP)
def _interactive_state(self, target_id: str, rect: rl.Rectangle, *, pad_y: float = 0) -> tuple[bool, bool]:
self._interactive_rects[target_id] = rect
hovered = _point_hits(gui_app.last_mouse_event.pos, rect, self._scroll_rect, pad_x=6, pad_y=pad_y)
return hovered, self._pressed_target == target_id
def _clear_state(self):
self._pressed_target = None
self._can_click = True
def show_event(self):
super().show_event()
self._clear_state()
def hide_event(self):
super().hide_event()
self._clear_state()
def _handle_mouse_press(self, mouse_pos: MousePos):
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():
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
def _handle_mouse_release(self, mouse_pos: MousePos):
target = self._target_at(mouse_pos) if self._scroll_panel.is_touch_valid() else None
if self._pressed_target is not None and self._pressed_target == target and self._can_click:
self._activate_target(target)
self._pressed_target = None
self._can_click = True
def _target_at(self, mouse_pos: MousePos) -> str | None:
for target_id, rect in self._interactive_rects.items():
if _point_hits(mouse_pos, rect, self._scroll_rect, pad_x=6, pad_y=0):
return target_id
return None
return sum(sections) + SECTION_GAP * (len(sections) - 1)
def _activate_target(self, target_id: str | None):
if not target_id:
@@ -316,26 +273,10 @@ class VehicleSettingsManagerView(Widget):
self._draw_controls_tab(y, rect.x, width)
def _draw_tabs(self, rect: rl.Rectangle):
if not self._tab_defs:
return
available_w = max(1.0, rect.width)
tab_w = (available_w - self.TAB_GAP * max(0, len(self._tab_defs) - 1)) / max(1, len(self._tab_defs))
for index, tab in enumerate(self._tab_defs):
tab_rect = rl.Rectangle(rect.x + index * (tab_w + self.TAB_GAP), rect.y, tab_w, self.TAB_HEIGHT)
target_id = f"tab:{tab['id']}"
hovered, pressed = self._interactive_state(target_id, tab_rect, pad_y=4)
draw_tab_card(
tab_rect,
tab["title"],
self._tab_subtitle(tab["id"]),
current=self._active_tab_key == tab["id"],
hovered=hovered,
pressed=pressed,
title_size=26,
subtitle_size=17,
show_underline=True,
style=PANEL_STYLE,
)
draw_tab_bar(
rect, self._tab_defs, self._active_tab_key, self._interactive_state,
subtitle_fn=self._tab_subtitle, style=PANEL_STYLE,
)
def _draw_identity_tab(self, y: float, x: float, width: float):
rows = [